lodash isEqual performance (v2)

Revision 2 of this benchmark created on


Preparation HTML

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.5.0/lodash.min.js"></script>

Setup

// @ts-nocheck

// 1. Deeply nested objects (tests recursion depth)
const deepNested1 = {a: {b: {c: {d: {e: {f: {g: {h: {i: {j: {k: 1}}}}}}}}}}};
const deepNested2 = {a: {b: {c: {d: {e: {f: {g: {h: {i: {j: {k: 1}}}}}}}}}}};
const deepNested3 = {a: {b: {c: {d: {e: {f: {g: {h: {i: {j: {k: 2}}}}}}}}}}};

// 2. Wide objects (many keys at same level)
const wide1 = Object.fromEntries(Array.from({length: 1000}, (_, i) => [`key${i}`, i]));
const wide2 = Object.fromEntries(Array.from({length: 1000}, (_, i) => [`key${i}`, i]));
const wide3 = {...wide2, key999: 'different'};

// 3. Large arrays
const largeArr1 = Array.from({length: 10000}, (_, i) => ({id: i, value: `item${i}`}));
const largeArr2 = Array.from({length: 10000}, (_, i) => ({id: i, value: `item${i}`}));
const largeArr3 = [...largeArr2.slice(0, 9999), {id: 9999, value: 'changed'}];

// 4. Mixed types with nulls/undefined (tests your null handling)
const mixed1 = {
    str: 'hello',
    num: 42,
    bool: true,
    nil: null,
    undef: undefined,
    date: new Date('2026-01-08'),
    arr: [1, null, undefined, {nested: null}],
    obj: {a: null, b: undefined, c: {d: null}}
};
const mixed2 = {
    str: 'hello',
    num: 42,
    bool: true,
    nil: undefined,  // null vs undefined
    undef: null,     // undefined vs null
    date: new Date('2026-01-08'),
    arr: [1, undefined, null, {nested: undefined}],  // swapped
    obj: {a: undefined, b: null, c: {d: undefined}}  // swapped
};

// 5. Sparse vs dense (missing keys = undefined)
const sparse1 = {a: 1, c: 3, e: 5};
const sparse2 = {a: 1, b: undefined, c: 3, d: null, e: 5};

// 6. Worst case: identical large nested structure
const createDeepWide = (depth, width) => {
    if (depth === 0) return {value: Math.random()};
    return Object.fromEntries(
        Array.from({length: width}, (_, i) => [`child${i}`, createDeepWide(depth - 1, width)])
    );
};
const deepWide1 = createDeepWide(5, 10);  // 10^5 = 100,000 leaf nodes
const deepWide2 = JSON.parse(JSON.stringify(deepWide1));  // clone

// [name, obj1, obj2, iterations, expectedResult]
const testSets = [
    [deepNested1, deepNested2],
    [deepNested1, deepNested3],
    [wide1, wide2],
    [ wide1, wide3],
    [ largeArr1, largeArr2],
    [ largeArr1, largeArr3],
    [ mixed1, mixed2],
    [ sparse1, sparse2],
    [deepWide1, deepWide2],
];

function isEquals(obj1, obj2, options = {}) {
    const {treatNullsAndUndefinedAsEqual = true} = options;

    // Direct comparison for primitives and same references
    if (obj1 === obj2) {
        return true;
    }

    // Handle null/undefined comparison based on options
    const obj1IsNullish = obj1 === null || obj1 === undefined;
    const obj2IsNullish = obj2 === null || obj2 === undefined;

    if (obj1IsNullish && obj2IsNullish) {
        return treatNullsAndUndefinedAsEqual ? true : obj1 === obj2;
    }

    // One is null/undefined, the other is not — not equal
    if (obj1IsNullish || obj2IsNullish) {
        return false;
    }

    // Date comparison
    if (obj1 instanceof Date && obj2 instanceof Date) {
        return obj1.getTime() === obj2.getTime();
    }

    // If either is not an object, they're not equal (primitives would have matched above)
    if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
        return false;
    }

    // Array type mismatch
    const obj1IsArray = Array.isArray(obj1);
    const obj2IsArray = Array.isArray(obj2);

    if (obj1IsArray !== obj2IsArray) {
        return false;
    }

    // Compare keys from obj1 — missing keys in obj2 will be undefined
    for (const key of Object.keys(obj1)) {
        if (!isEquals(obj1[key], obj2[key], options)) {
            return false;
        }
    }

    // Compare keys from obj2 — missing keys in obj1 will be undefined
    for (const key of Object.keys(obj2)) {
        if (!isEquals(obj1[key], obj2[key], options)) {
            return false;
        }
    }

    return true;
}



function isEquals2(obj1, obj2, options= {}){
    const treatNullsAndUndefinedAsEqual = options.treatNullsAndUndefinedAsEqual ?? true;
    return isEqualsInternal(obj1, obj2, treatNullsAndUndefinedAsEqual);
}

function isEqualsInternal(obj1, obj2, treatNullsAndUndefinedAsEqual){
    // Direct comparison for primitives and same references
    if (obj1 === obj2) {
        return true;
    }

    // Handle null/undefined comparison
    const obj1IsNullish = obj1 === null || obj1 === undefined;
    const obj2IsNullish = obj2 === null || obj2 === undefined;

    if (obj1IsNullish && obj2IsNullish) {
        return treatNullsAndUndefinedAsEqual || obj1 === obj2;
    }

    if (obj1IsNullish || obj2IsNullish) {
        return false;
    }

    // Date comparison
    if (obj1 instanceof Date && obj2 instanceof Date) {
        return obj1.getTime() === obj2.getTime();
    }

    // If either is not an object, they're not equal
    if (typeof obj1 !== 'object' || typeof obj2 !== 'object') {
        return false;
    }

    // Array comparison with length check first
    const obj1IsArray = Array.isArray(obj1);
    const obj2IsArray = Array.isArray(obj2);

    if (obj1IsArray !== obj2IsArray) {
        return false;
    }

    if (obj1IsArray) {
        // Early exit if lengths differ
        if (obj1.length !== obj2.length) {
            return false;
        }
        for (let i = 0; i < obj1.length; i++) {
            if (!isEqualsInternal(obj1[i], obj2[i], treatNullsAndUndefinedAsEqual)) {
                return false;
            }
        }
        return true;
    }

    // Object comparison
    const keys1 = Object.keys(obj1);
    const keys2 = Object.keys(obj2);

    // Fast path: when not treating nulls as equal, key counts must match
    if (!treatNullsAndUndefinedAsEqual && keys1.length !== keys2.length) {
        return false;
    }

    // Compare all keys from obj1
    for (const key of keys1) {
        if (!isEqualsInternal(obj1[key], obj2[key], treatNullsAndUndefinedAsEqual)) {
            return false;
        }
    }

    // Only check extra keys in obj2 (keys not in obj1)
    // This avoids comparing shared keys twice
    if (keys2.length > keys1.length || treatNullsAndUndefinedAsEqual) {
        for (const key of keys2) {
            if (!(key in obj1)) {
                // Key only exists in obj2 - compare against undefined
                if (!isEqualsInternal(undefined, obj2[key], treatNullsAndUndefinedAsEqual)) {
                    return false;
                }
            }
        }
    }

    return true;
}

Test runner

Ready to run.

Testing in
TestOps/sec
comparison with "lodash.isEqual"
for (const [a, b] of testSets) {
  _.isEqual(a, b)
}
ready
comparison with "isEquals" (manual function)
for (const [a, b] of testSets) {
  isEquals(a, b)
}
ready
comparison with "isEquals2" (manual function)
for (const [a, b] of testSets) {
  isEquals2(a, b)
}
ready

Revisions

You can edit these tests or add more tests to this page by appending /edit to the URL.