5 Different Ways to Deep Compare JavaScript Objects

Summarize this blog post with:

TL;DR: Many JavaScript bugs begin with a simple assumption: two objects with the same values should be equal. Because JavaScript compares objects by reference, deep comparison often requires a different approach. Use a custom recursive function, JSON.stringify() for controlled data, Lodash’s _.isEqual(), specialized equality libraries, or platform APIs like Node.js util.isDeepStrictEqual(), depending on your data, performance needs, and runtime constraints.

Why do two JavaScript objects with the same values sometimes fail an equality check? The answer has less to do with the data they contain and more to do with how JavaScript handles object references. It’s a subtle behavior that catches many developers off guard and can lead to unexpected bugs in production applications.

Whether you’re comparing application state, validating API responses, tracking form changes, or writing tests, choosing the wrong approach can produce unreliable results.

Let’s examine five deep comparison techniques, where each one shines, and the edge cases that can make or break your implementation.

Quick answer: Which comparison method should you use?

Use the following recommendations as a starting point:

  • For JSON-safe data where serialization order is controlled, and serialization semantics match your equality requirements: Use JSON.stringify() only as a controlled shortcut.
  • For general browser or application code: Use a tested comparison utility such as Lodash’s _.isEqual().
  • For Node.js applications: Use util.isDeepStrictEqual() when its equality rules match your requirements.
  • For Jest or Vitest tests: Use the test framework’s built-in equality matcher.
  • For known object structures or custom rules: Write a schema-specific comparator or use a customizable library API.
  • For performance-sensitive code: Compare stable identifiers or version values when possible, and benchmark with representative application data before choosing a deep-comparison implementation.

Types of object equality in JavaScript: Understanding why object comparisons behave differently

Before exploring these methods, it helps to understand the three types of object equality you’ll encounter in JavaScript. This distinction explains why seemingly identical objects can behave differently during comparisons.

  • Referential equality: Determines whether two variables refer to the same object. This can be checked using strict equality (===), coercive equality (==), or Object.is().
  • Shallow equality: Compares only an object’s immediate properties. Nested objects are still compared by reference.
  • Deep equality: Recursively compares properties and nested values according to defined comparison rules.
const a = { user: { id: 1 } };
const b = { user: { id: 1 } };
const c = a;

console.log(a === b); // false - different references
console.log(a === c); // true - same reference

A shallow comparison of a and b would also find their user properties unequal because they reference different nested objects. A deep comparison can consider them equal because the nested values match.

Avoid subtle bugs caused by incorrect object comparisons

At first glance, deep comparison seems straightforward: check whether two objects contain the same values. In practice, things become much more complicated. Modern JavaScript applications often work with nested arrays, dates, maps, sets, typed arrays, and even circular references. Each of these types introduces different comparison challenges.

For example:

  • Should 0 and -0 be equal?
  • Should object prototypes match?
  • Should symbol-keyed or non-enumerable properties be compared?
  • Should Set insertion order matter?
  • Should functions be compared only by reference?

In practice, there isn’t a perfect deep-comparison solution. Every method makes trade-offs, which is why understanding your data structure matters more than memorizing a particular library.

1. Manual comparison: Build your own comparator when you need full control

Best for: Known data structures, custom equality rules, and applications that do not require an external dependency.

If you know exactly what your data looks like, a custom comparator can be the most predictable solution. Instead of depending on a library’s predefined equality rules, you decide which value types matter and how they should be compared.

A manual recursive function walks through two values and determines whether they are deeply equal. This approach gives you full control over the supported value types and comparison rules.

Key rules a comparator should handle

  • Primitives: Use Object.is() when its treatment of NaN and signed zero matches your requirements.
  • Dates: Compare the values returned by getTime().
  • Regular expressions: Compare their source and flags.
  • Arrays: Compare their lengths and values in order.
  • Plain objects: Compare their keys and recursively compare the values.
  • Maps and Sets: Define whether entries are compared by order, membership, identity, or deep value.
  • Circular and shared references: Track object pairs in both directions to prevent infinite recursion and preserve aliasing relationships.
  • Property coverage: Decide whether prototypes, symbols, non-enumerable properties, inherited properties, and descriptors affect equality. The example compares matching plain-object prototypes and own enumerable string-keyed properties.

Example

The following comparator supports primitives, arrays, and plain objects. It tracks object pairs in both directions and rejects specialized objects that require type-specific rules.

function isPlainObject(value) {
  if (value === null || typeof value !== "object") {
    return false;
  }

  const prototype = Object.getPrototypeOf(value);

  return prototype === Object.prototype || prototype === null;
}

function deepEqual(
  a,
  b,
  seenA = new WeakMap(),
  seenB = new WeakMap()
) {
  if (Object.is(a, b)) {
    return true;
  }

  if (
    a === null ||
    b === null ||
    typeof a !== "object" ||
    typeof b !== "object"
  ) {
    return false;
  }

  const aIsArray = Array.isArray(a);
  const bIsArray = Array.isArray(b);

  if (aIsArray !== bIsArray) {
    return false;
  }

  if (!aIsArray) {
    if (!isPlainObject(a) || !isPlainObject(b)) {
      return false;
    }

    if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) {
      return false;
    }
  }

  if (aIsArray && a.length !== b.length) {
    return false;
  }

  if (seenA.has(a) || seenB.has(b)) {
    return seenA.get(a) === b && seenB.get(b) === a;
  }

  seenA.set(a, b);
  seenB.set(b, a);

  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);

  if (aKeys.length !== bKeys.length) {
    return false;
  }

  return aKeys.every(
    (key) =>
      Object.prototype.hasOwnProperty.call(b, key) &&
      deepEqual(a[key], b[key], seenA, seenB)
  );
}

const object1 = {
  user: { id: 1 },
  roles: ["admin", "editor"],
};

const object2 = {
  roles: ["admin", "editor"],
  user: { id: 1 },
};

console.log(deepEqual(object1, object2)); // true

console.log(deepEqual([1], { 0: 1 })); // false

console.log(deepEqual([], new Array(3))); // false

const a1 = {};
const a2 = {};
const b1 = {};

const graphA = {
  first: a1,
  second: a2,
};

const graphB = {
  first: b1,
  second: b1,
};

console.log(deepEqual(graphA, graphB)); // false

In graphA, first and second reference different objects, while both properties in graphB share one object. Two-way WeakMap tracking preserves this difference, so the comparison returns false. Because the comparator uses Object.keys(), it ignores symbol-keyed, non-enumerable, and inherited properties.

When to use

  • You know the structure of the data you need to compare.
  • You need domain-specific equality rules.
  • You want to avoid external dependencies.
  • You can thoroughly test the comparator against expected edge cases.
  • A specialized function is justified for a performance-sensitive code path.

Limitations

  • Supports only primitives, arrays, and plain objects. Specialized objects compare as unequal unless they are the same reference.
  • keys() excludes symbol-keyed, non-enumerable, and inherited properties.
  • Maintain tests whenever supported values or equality rules change.

2. JSON.stringify(): The quick shortcut that works only in specific cases

Best for: Controlled JSON-safe data with consistent serialization order, where JSON serialization accurately represents the equality semantics your application requires.

When developers first encounter deep comparison problems, JSON.stringify() is often the first solution they discover. It can work surprisingly well in controlled situations, but it comes with several limitations that make it unsuitable as a general-purpose deep equality solution.

JSON.stringify() converts an object to a JSON string. JavaScript can then compare the resulting strings with ===.

const person1 = {
  firstName: "John",
  lastName: "Doe",
  age: 35,
};

const person2 = {
  firstName: "John",
  lastName: "Doe",
  age: 35,
};

console.log(
  JSON.stringify(person1) === JSON.stringify(person2)
); // true

The property-order problem

Although JSON.stringify() follows a defined property-visiting order, objects containing the same properties can produce different strings when those properties were inserted in different orders.

const person1 = {
    firstName: "John",
    lastName: "Doe",
    age: 35,
};

const person2 = {
    age: 35,
    firstName: "John",
    lastName: "Doe",
};

console.log(
JSON.stringify(person1) === JSON.stringify(person2)
); // false

The objects contain the same values, but their serialized strings differ.

When to use

  • JSON serialization preserves every value that affects equality.
  • Property insertion order is controlled and consistent.
  • The data contains no circular references or BigInt values.
  • The comparison is a small, noncritical shortcut rather than a general equality utility.

Limitations

  • undefined, functions, and symbol-valued properties are omitted when encountered in objects.
  • Unsupported array values such as undefined and functions are serialized as null.
  • NaN, Infinity, and -Infinity become null.
  • Date values are serialized through their toJSON() representation, which normally produces an ISO-format string.
  • Map and Set entries are not meaningfully represented without custom serialization.
  • Circular references and encountered BigInt values can cause JSON.stringify() to throw a TypeError.
  • Different property insertion orders can produce different strings.

Therefore, JSON.stringify() is not a general-purpose deep-equality algorithm. Use it only when the data contract is controlled, and its serialization rules are acceptable.

3. Lodash _.isEqual and _.isEqualWith(): The most practical choice for everyday applications

Best for: General application use when you need a tested comparator for commonly used JavaScript value types.

For most application code, developers don’t want to maintain a comparison algorithm themselves. They simply need a reliable solution that handles common JavaScript value types correctly. That’s where Lodash’s comparison utilities can be useful.

Lodash’s _.isEqual() performs a deep equivalence comparison. It supports arrays, ArrayBuffers, booleans, Dates, Errors, Maps, numbers, objects, regular expressions, Sets, strings, symbols, and typed arrays. Objects are compared using their own enumerable properties, while functions and DOM nodes are compared using strict equality.

import isEqual from "lodash/isEqual";

const person1 = { 
    firstName: "John",
    lastName: "Doe",
    age: 35
};

const person2 = 
{ 
    firstName: "John",
    lastName: "Doe",
    age: 35 
};

console.log(isEqual(person1, person2)); // true

Projects using the ES module build can use the import style supported by their configuration:

import { isEqual } from "lodash-es";

The standalone lodash.isequal package is deprecated. Existing Lodash projects can import lodash/isEqual; Node.js projects can evaluate util.isDeepStrictEqual() when its semantics match their requirements.

Custom comparison with _.isEqualWith()

Use _.isEqualWith() when you need specific rules, such as ignoring timestamps.

import isEqualWith from "lodash/isEqualWith";

const record1 = {
    id: 1,
    name: "John",
    updatedAt: "2026-08-20T10:00:00Z",
};

const record2 = {
    id: 1,
    name: "John",
    updatedAt: "2026-08-21T09:30:00Z",
};

const result = isEqualWith(record1, record2, (value1, value2, key) => {
    if (key === "updatedAt") {
        return true;
    }

    return undefined;
});

console.log(result); // true

The customizer’s third argument can represent an object property key or an array index. Returning undefined tells Lodash to apply its normal comparison behavior when the customizer does not provide a result.

When to use

  • You need to compare several common JavaScript data types.
  • The project already uses Lodash.
  • You need custom rules through isEqualWith().
  • You prefer a tested utility over maintaining a general comparator.

Limitations

  • It introduces or reuses an external dependency.
  • Bundle impact depends on the import path, Lodash build, bundler, and tree-shaking configuration.
  • Its predefined equality rules may not match every application.
  • A schema-specific comparator may be more appropriate when only selected properties matter.

4. The deep-equal Library: When you need alternative equality behavior

Best for: Existing projects or specialized requirements that specifically depend on the package’s configurable loose or strict leaf-comparison behavior.

The deep-equal library accepts an optional strict setting. Its default loose behavior can consider values equal after coercion.

const deepEqual = require("deep-equal");

const person1 = {
    firstName: "John",
    lastName: "Doe",
    age: 35
};
const person2 = {
    firstName: "John",
    lastName: "Doe",
    age: "35" 
}; // age is a string here

console.log(deepEqual(person1, person2)); // true
console.log(deepEqual(person1, person2, { strict: true })); // false

When the values and their types match:

const person1 = {
    firstName: "John",
    lastName: "Doe",
    age: 35,
};

const person2 = {
    firstName: "John",
    lastName: "Doe",
    age: 35,
};

console.log(
    deepEqual(person1, person2, { strict: true })
); // true

Use { strict: true } when leaf values must use === instead of coercive ==. This option does not guarantee the same behavior as other strict deep-equality implementations. Before adopting the package, validate its maintenance status, compatibility, TypeScript support, circular-reference handling, and behavior for your application’s value types.

When to use

  • Its strict and loose comparison modes match a documented application requirement.
  • You have validated it against the project’s data, runtime, and maintenance requirements.

Limitations

  • Loose comparison can hide type differences.
  • Verify behavior for specialized values, prototypes, and circular references.
  • It does not provide an equivalent to isEqualWith() for detailed customization.

5. Runtime and Test Framework Equality APIs: Use built-in tools when your environment already provides them

Best for: Code running in an environment that already provides an equality API with semantics appropriate for the task.

Before adding another dependency to your project, check whether your runtime environment or test framework already provides the comparison functionality you need.

Node.js: assert.deepStrictEqual() and util.isDeepStrictEqual()

Node.js provides two related APIs:

  • deepStrictEqual(actual, expected) performs an assertion and throws an AssertionError when the values are unequal.
  • util.isDeepStrictEqual(value1, value2) returns a Boolean value.
import assert from "node:assert/strict";
import { isDeepStrictEqual } from "node:util";

const actual = {
    user: { id: 1 },
    roles: ["admin"],
};

const expected = {
    user: { id: 1 },
    roles: ["admin"],
};

assert.deepStrictEqual(actual, expected);

console.log(
    isDeepStrictEqual(actual, expected)
); // true

Use assert.deepStrictEqual() when a failed comparison should throw. Use util.isDeepStrictEqual() when application logic requires a Boolean result. These methods are available in Node.js and should not be presented as browser-native APIs.

Node.js strict deep equality normally considers prototypes and constructors as part of the comparison. Check the documentation for the Node.js version used by your project before relying on version-specific options that modify prototype handling.

Jest and Vitest: toEqual()

Jest and Vitest provide equality matchers that integrate deep comparison with assertion reporting and failure diffs.

expect(person1).toEqual(person2); // passes; shows diff on failure

Use toEqual() for ordinary deep-value assertions and use toStrictEqual() when distinctions involving object types, undefined properties, or sparse arrays must affect the test result.

AngularJS 1.x: angular.equals()

AngularJS 1.x provides angular.equals(object1, object2). This API belongs to legacy AngularJS, not modern Angular. Official AngularJS support ended in January 2022, so use this method only when maintaining an existing AngularJS application.

const person1 = {
    firstName: "John",
    lastName: "Doe",
    age: 35,
};

const person2 = {
    firstName: "John",
    lastName: "Doe",
    age: 35,
};

console.log(
    angular.equals(person1, person2)
); // true

AngularJS applies several framework-specific rules:

  • NaN is considered equal to NaN.
  • Function-valued properties are ignored during property comparison.
  • Properties whose names begin with $ are ignored.
  • Regular expressions are compared according to their textual representation.
  • Scope and window objects are compared by identity.

These rules can produce different results from other deep-equality implementations.

When to use

  • The runtime already provides an appropriate comparison method.
  • You are writing test assertions.
  • You want to avoid an additional application dependency.

Limitations

  • Platform APIs are not always portable.
  • Assertion methods may throw instead of returning a Boolean.
  • Testing matchers are intended for tests rather than application logic.
  • AngularJS comparison rules should not be generalized to modern Angular.

How to deep compare objects in TypeScript

TypeScript types are removed during compilation. Therefore, comparing typed objects still requires a runtime JavaScript comparison method.

import isEqual from "lodash/isEqual";

interface UserSettings {
    theme: "light" | "dark";
    notifications: {
        email: boolean;
        push: boolean;
    };
}

const settings1: UserSettings = {
    theme: "dark",
    notifications: {
        email: true,
        push: false,
    },
};

const settings2: UserSettings = {
    theme: "dark",
    notifications: {
        email: true,
        push: false,
    },
};

console.log(isEqual(settings1, settings2)); // true

Typing both values as UserSettings can identify incompatible structures during development, but it does not automatically compare their values at runtime. The selected comparator must still support all runtime values contained in the objects.

Deep equality versus finding object differences

A deep-equality function usually returns only true or false. It does not identify the properties that were added, removed, or changed.

For example:

const before = {
    name: "John",
    role: "Editor",
};

const after = {
    name: "John",
    role: "Administrator",
};

A deep comparison reports that these objects are unequal. If you need to identify that the role property changed, use an object-diff algorithm or library instead of an equality function.

Real-world example: Deep comparison in React applications

Many developers encounter deep comparison problems while working with React.

Consider a component that receives user data from an API:

const previousUser = {
  profile: {
    name: "John",
  },
};

const currentUser = {
  profile: {
    name: "John",
  },
};

console.log(previousUser === currentUser); // false

Although both objects contain the same values, React sees them as different references.

This issue commonly appears when:

  • Preventing unnecessary re-renders,
  • Comparing API responses,
  • Implementing custom memoization logic,
  • Detecting unsaved form changes, and
  • Synchronizing application state.

In these situations, a deep comparison may be necessary. However, performing deep equality checks on every render can become expensive, so many applications rely on stable IDs, version numbers, or carefully structured state updates whenever possible.

This is why choosing the right comparison strategy matters. The best solution depends not only on correctness but also on performance and maintainability.

Which deep comparison approach should you use?

There is no universally correct deep-comparison solution. The best approach depends on the types of values you need to compare, your performance requirements, the execution environment, and whether custom equality rules are needed.

Scenario

Recommended method

Reason

JSON-safe data with controlled serialization order and compatible serialization semantics

JSON.stringify() with caution

Simple, but limited by serialization behavior

General application use

Lodash _.isEqual()

Supports many common JavaScript value types

Custom comparison rules

_.isEqualWith()or a custom comparator

Supports application-specific behavior

Existing or specialized strict/loose comparison requirement

deep-equal after validating its package-specific semantics

Provides configurable strict or loose comparison

Node.js Boolean comparison

util.isDeepStrictEqual()

Built into Node.js and returns a Boolean

Node.js test assertion

assert.deepStrictEqual()

Throws when the values are unequal

Jest or Vitest testing

toEqual() or toStrictEqual(), based on the required semantics

Produces test-oriented failure information

Legacy AngularJS application

angular.equals()

 

Available with AngularJS-specific equality rules

Known data structure

Manual comparator

Provides control over supported values and rules

Need changed property details

Object-diff solution

Deep equality does not identify differences

When deep comparison becomes a performance problem

Deep comparison is powerful, but it is not always the best solution.

Imagine comparing two deeply nested objects containing thousands of properties every time a component renders or an API request completes. Even efficient comparison algorithms can become a noticeable bottleneck when executed frequently.

Before reaching for a deep-comparison library, consider whether one of these alternatives might be sufficient:

  • Compare stable IDs instead of entire objects.
  • Track version numbers or timestamps.
  • Compare only properties relevant to the business logic.
  • Normalize data structures to reduce comparison complexity.

In many applications, avoiding a deep comparison entirely produces both simpler code and better performance.

Frequently Asked Questions

Does deep object comparison include symbol and non-enumerable properties?

Not always. Comparison rules vary. Some methods compare only enumerable string-keyed properties, while others also consider enumerable symbol properties, prototypes, constructors, or other object metadata. Non-enumerable properties are frequently excluded. Verify the selected comparator’s documented rules when any of these characteristics affect equality.

Should two Sets be equal when their insertion order differs?

It depends on the comparator’s rules. A Set normally represents unordered membership, but object members may still require a decision between reference identity and deep value equality.

What is the fastest way to deep compare objects in JavaScript?

There is no universally fastest method. For performance-sensitive code, compare stable identifiers, version numbers, or only the required properties when possible. If full deep comparison is necessary, benchmark suitable methods using data that represents your application.

How can I ignore timestamps or IDs during comparison?

Compare only the required properties, normalize the objects before comparison, or use a customizable method such as _.isEqualWith(). Document the ignored fields to avoid unexpected matches.

Is deep comparison necessary for every object check?

No. Reference equality, shallow equality, stable IDs, or selected-property comparison may be sufficient. Use deep comparison only when the application must evaluate complete nested content.

Choose the simplest comparison that solves the problem

Deep comparison is often necessary when you need to compare object contents rather than object references. But not every use case requires a full recursive equality check.

  • Use JSON.stringify() for controlled, JSON-safe data where serialization behavior aligns with your requirements.
  • For most applications, Lodash’s _.isEqual() provides a reliable and well-tested deep comparison solution.
  • When specific comparison rules are needed, _.isEqualWith() or a custom comparator offers greater flexibility.
  • In Node.js applications and test environments, built-in equality APIs may eliminate the need for additional dependencies.

The key isn’t choosing the most powerful comparison library. It’s defining what “equal” means for your application.

Once you’ve identified:

  • which values are important,
  • which edge cases need to be handled, and
  • how frequently comparisons occur,

selecting the right approach becomes much more straightforward.

In many real-world scenarios, the most efficient deep comparison is the one you avoid altogether. Stable identifiers, version numbers, or targeted property checks are often simpler, faster, and easier to maintain than comparing entire object graphs.

Building robust JavaScript applications involves more than comparing data correctly. If you’re working with complex datasets, interactive dashboards, or data-intensive user interfaces, explore Syncfusion JavaScript UI Controls to accelerate development with a comprehensive collection of high-performance, production-ready components.

If you’re already a Syncfusion user, you can download the latest version from the license and downloads page. New users can get started with a free 30-day trial.

For questions or assistance, reach out through the support forumsupport portal, or feedback portal. We’re always happy to help.

Be the first to get updates

Mahesh SamarasingheMahesh Samarasinghe profile icon

Meet the Author

Mahesh Samarasinghe

I am a full-stack software engineer with over two years of experience working in the MERN stack and AWS. I am also an AWS Community Builder and a content writer in multiple platforms.

Leave a comment