JavaScript object comparison is one of those things that looks simple until you hit a bug at 11 PM because two objects that look identical in the console are apparently not equal. This guide covers all six practical methods for comparing objects in JavaScript — from the quick-and-dirty JSON.stringify trick to production-grade deep equality libraries — with honest coverage of where each one breaks and when to reach for a visual diff tool instead of writing more code.

Why === Fails for Objects (Reference vs. Value)

Before picking a comparison method, you need to understand why the obvious approach fails. In JavaScript, the strict equality operator === performs reference comparison for objects, not value comparison (see the MDN reference on equality comparisons and sameness for the underlying spec). Two separate object literals with identical properties are stored at different memory addresses, so === returns false even when every property matches.

const a = { name: "Alice", age: 30 };
const b = { name: "Alice", age: 30 };

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

const c = a;          // c points to the same object as a
console.log(a === c); // true  — same reference again

This reference-vs-value distinction is the root of nearly every js comparing objects bug. You are not testing whether two objects have the same data — you are testing whether two variables point to the exact same object in memory. For primitives (strings, numbers, booleans), === compares value and works as expected. For objects, you need a different strategy.

If you want a deeper look at how equality operators work in JavaScript generally, the article on the equal sign in JavaScript covers the full spectrum from == to Object.is.

Reference Comparison: Why === Fails for Objects obj1 0xA1B2 obj2 0xC3D4 Heap 0xA1B2 name: "Alice" age: 30 role: "admin" Heap 0xC3D4 name: "Alice" age: 30 role: "admin" === false Same data, different memory addresses — === compares addresses, not values

Quick Method Matrix: JSON.stringify vs. Lodash vs. Manual

Here is the landscape before we dive into each method. When you need to compare two objects javascript-style, the right pick depends on whether you have nested data, special types like Date, or circular references. Use this as a quick reference when you know your constraints and just need to pick something fast.

Method Handles Nested Handles Dates Handles NaN/+0 Circular Refs Performance When to Use
JSON.stringify Yes Partial (ISO string) No Throws Fast Simple plain objects, no special types
Lodash _.isEqual Yes Yes Yes Yes Moderate Production default, all edge cases covered
Recursive custom Yes DIY DIY DIY Varies When you need custom comparison logic
fast-deep-equal Yes Yes Partial No (default) Fastest Performance-critical paths, large objects
Object.keys iteration Shallow only No No N/A Fast Shallow comparison, controlled shapes
structuredClone hybrid Yes Yes (native clone) Yes Yes Moderate Modern environments, no library budget

The sections below walk through each method with working code, the exact js comparing objects edge cases that bite developers, and when to move on to something else.

Method 1: JSON.stringify() — Speed, Pitfalls & Key-Order Trap

The JSON.stringify approach converts both objects to JSON strings and compares the strings. It is the first thing most developers reach for and it works well for a narrow but common case.

function jsonEqual(a, b) {
  return JSON.stringify(a) === JSON.stringify(b);
}

const user1 = { name: "Alice", role: "admin" };
const user2 = { name: "Alice", role: "admin" };
const user3 = { role: "admin", name: "Alice" }; // different insertion order

console.log(jsonEqual(user1, user2)); // true
console.log(jsonEqual(user1, user3)); // false — key order differs!

The key-order trap is the most common failure mode. JSON serialization preserves the order keys were inserted, and two objects built from different code paths may have the same properties in different orders. The fix is to sort keys before stringifying:

function stableJsonEqual(a, b) {
  const sortedStringify = (obj) =>
    JSON.stringify(obj, Object.keys(obj).sort());

  return sortedStringify(a) === sortedStringify(b);
}

// But this only sorts the top-level keys!
// For deep sorting, you need a recursive replacer:
function deepSortedStringify(obj) {
  return JSON.stringify(obj, (key, value) => {
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      return Object.keys(value)
        .sort()
        .reduce((sorted, k) => {
          sorted[k] = value[k];
          return sorted;
        }, {});
    }
    return value;
  });
}

const a = { z: 1, a: { y: 2, x: 3 } };
const b = { a: { x: 3, y: 2 }, z: 1 };
console.log(deepSortedStringify(a) === deepSortedStringify(b)); // true

What JSON.stringify silently drops or mangles:

  • undefined values — the key disappears entirely from the output
  • Functions — dropped without warning
  • Symbol keys — ignored
  • NaN and Infinity — serialized as null
  • Date objects — converted to ISO strings (so two different Date objects for the same moment compare equal, but a Date and a pre-existing ISO string also wrongly compare equal)
  • Circular references — throws TypeError: Converting circular structure to JSON

Use JSON.stringify when: objects are plain data, built from API JSON responses, and you can guarantee no special types. Avoid it the moment Date, Map, Set, or functions enter the picture.

JSON.stringify Key-Order Trap Object A (inserted: name first) { name: "Alice", role: "admin" } Object B (inserted: role first) { role: "admin", name: "Alice" } JSON.stringify() {"name":"Alice","role":"admin"} {"role":"admin","name":"Alice"} Output A !== Output B → false Objects are semantically equal — but stringify reports them as different

Method 2: Lodash _.isEqual() — The Production Standard

Lodash's _.isEqual is the safest general-purpose choice for comparing objects in JavaScript. It handles every edge case in the matrix above, is heavily battle-tested, and its behavior is well documented in the official Lodash isEqual docs.

import isEqual from 'lodash/isEqual'; // tree-shakeable import

const a = {
  name: "Alice",
  createdAt: new Date("2026-01-01"),
  tags: ["admin", "editor"],
};

const b = {
  name: "Alice",
  createdAt: new Date("2026-01-01"),
  tags: ["admin", "editor"],
};

console.log(isEqual(a, b)); // true — Date instances compared by value

// Works with nested objects
const x = { user: { address: { city: "Berlin", zip: "10115" } } };
const y = { user: { address: { city: "Berlin", zip: "10115" } } };
console.log(isEqual(x, y)); // true

// NaN equality (note: NaN !== NaN in plain JS)
console.log(isEqual({ x: NaN }, { x: NaN })); // true — lodash treats NaN as self-equal

// Handles arrays in correct order
console.log(isEqual([1, 2, 3], [3, 2, 1])); // false — order matters

Bundle size note: Importing the entire lodash package adds ~70 KB to your bundle. Import only the function you need: import isEqual from 'lodash/isEqual' or use the ESM version lodash-es which is fully tree-shakeable. The isEqual function itself is about 15 KB minified.

When Lodash is the right answer: when you need a one-liner that handles everything — Dates, RegExp, Maps, Sets, circular references, NaN, +0/-0 — and you already have Lodash in your dependency tree. For projects without Lodash, consider fast-deep-equal first (covered in Method 4) since it covers most cases with smaller bundle footprint, but note it doesn't handle circular refs or treat NaN as self-equal by default.

Method 3: Recursive Deep Comparison — Build Your Own

Writing your own deep equality function is a useful exercise and sometimes necessary when you have domain-specific comparison rules (ignore certain keys, compare arrays as sets, etc.). Here is a solid starting implementation that covers the common cases:

function deepEqual(a, b) {
  // Same reference or same primitive
  if (a === b) return true;

  // Handle null (typeof null === 'object')
  if (a === null || b === null) return false;

  // Handle NaN
  if (typeof a === 'number' && typeof b === 'number') {
    return Number.isNaN(a) && Number.isNaN(b);
  }

  // Different types
  if (typeof a !== typeof b) return false;

  // Non-object primitives that aren't === already
  if (typeof a !== 'object') return false;

  // Handle Date
  if (a instanceof Date && b instanceof Date) {
    return a.getTime() === b.getTime();
  }

  // Handle RegExp
  if (a instanceof RegExp && b instanceof RegExp) {
    return a.source === b.source && a.flags === b.flags;
  }

  // Handle Array
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  if (Array.isArray(a)) {
    if (a.length !== b.length) return false;
    return a.every((item, i) => deepEqual(item, b[i]));
  }

  // Handle plain object
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

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

// Usage
console.log(deepEqual(
  { x: 1, nested: { arr: [1, 2, 3] } },
  { x: 1, nested: { arr: [1, 2, 3] } }
)); // true

console.log(deepEqual(
  { x: NaN },
  { x: NaN }
)); // true

This implementation deliberately omits circular reference handling to keep it readable. If your objects can have cycles (parent/child references, DOM nodes, etc.), add a WeakSet to track visited nodes:

function deepEqualSafe(a, b, visited = new WeakSet()) {
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (typeof a !== 'object' || typeof b !== 'object') return false;

  // Circular reference guard
  if (visited.has(a)) return true;
  visited.add(a);

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every(key =>
    Object.prototype.hasOwnProperty.call(b, key) &&
    deepEqualSafe(a[key], b[key], visited)
  );
}
Recursive deepEqual — Call Tree deepEqual(objA, objB) depth 0 — compare root object key: "x" 1 === 1 → true key: "nested" object → recurse key: "tags" array → recurse key: "city" "Berlin" === "Berlin" key: "zip" "10115" === "10115" All leaves equal → deepEqual returns true

Method 4: fast-deep-equal Library

fast-deep-equal (v3.1.3) is an 800-byte library that focuses on doing one thing extremely well: deep equality as fast as possible. Published benchmarks show it significantly outperforms Lodash _.isEqual because it avoids the overhead of Lodash's comprehensive type-handling infrastructure. However, benchmark results vary by data shape — run benchmarks on your actual data before making performance-critical decisions.

// npm install fast-deep-equal
import equal from 'fast-deep-equal';
// For ES2015+ types (Map, Set):
import equal from 'fast-deep-equal/es6';

const response1 = {
  status: 200,
  data: {
    users: [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ],
    pagination: { page: 1, total: 2 },
  },
};

const response2 = {
  status: 200,
  data: {
    users: [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ],
    pagination: { page: 1, total: 2 },
  },
};

console.log(equal(response1, response2)); // true

Trade-offs vs. Lodash:

  • Default export does not handle circular references — throws or returns wrong result
  • Does not treat NaN as equal to NaN in the default build
  • No customization hooks (Lodash has _.isEqualWith for custom comparators)
  • Lighter bundle, significantly faster in benchmarks

The sweet spot for fast-deep-equal is comparing API response objects where you control the data shape, know circular references will not appear, and care about performance (e.g., inside a React rendering cycle or a hot path in a Node.js service). Note: the default export does not handle circular references and does not treat NaN as self-equal. For general-purpose use where you cannot control the input, Lodash is safer. For background on why NaN behaves oddly under === versus Object.is, see the section on equality in the JavaScript "vs" reference.

Method 5: Object.keys + Iteration Patterns

When you only need shallow comparison — one level deep — a manual Object.keys loop is fast, dependency-free, and easy to understand. This approach is common in React components comparing props or in state management when you know your state is flat.

function shallowEqual(a, b) {
  if (a === b) return true;
  if (typeof a !== 'object' || typeof b !== 'object') return false;
  if (a === null || b === null) return false;

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);

  if (keysA.length !== keysB.length) return false;

  return keysA.every(key => a[key] === b[key]);
}

// Works for flat objects
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 }); // false

// Fails for nested objects — reference comparison at the nested level
const nested1 = { user: { name: "Alice" } };
const nested2 = { user: { name: "Alice" } };
shallowEqual(nested1, nested2); // false — user objects are different references

A common extension is to include only specific keys — useful when you want to compare a subset of properties (ignore updatedAt, for example):

function partialEqual(a, b, keys) {
  return keys.every(key => a[key] === b[key]);
}

const userA = { id: 1, name: "Alice", updatedAt: "2026-01-01" };
const userB = { id: 1, name: "Alice", updatedAt: "2026-04-30" };

// Are the identity fields the same, ignoring timestamp?
console.log(partialEqual(userA, userB, ['id', 'name'])); // true

String comparison works differently — see the guide on JavaScript string equality for the full rundown on ===, localeCompare, and when they differ.

Method 6: structuredClone + JSON.stringify Hybrid

structuredClone is a native browser and Node.js API (available since Node 17.0, Chrome 98+, Safari 137+, Firefox 94+) that performs a deep clone using the Structured Clone Algorithm. It handles Date, Map, Set, ArrayBuffer, circular references, and more — making it significantly more capable than JSON round-trip cloning.

The hybrid approach: clone both objects with structuredClone to normalize their types, then stringify and compare. This buys you key-order normalization issues (still need sorted stringify) but gains correct Date and circular ref handling:

function structuredEqual(a, b) {
  // structuredClone normalizes the objects (handles Date, Map, Set, circular refs)
  const cloneA = structuredClone(a);
  const cloneB = structuredClone(b);

  // Then use a sorted stringify for key-order stability
  const serialize = (obj) => JSON.stringify(obj, (key, value) => {
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      return Object.fromEntries(
        Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
      );
    }
    return value;
  });

  return serialize(cloneA) === serialize(cloneB);
}

// Date comparison works correctly
const a = { created: new Date("2026-01-01"), name: "test" };
const b = { created: new Date("2026-01-01"), name: "test" };
console.log(structuredEqual(a, b)); // true

// Circular references handled (structuredClone manages the cycle)
const circular = { name: "node" };
circular.self = circular;
const circularCopy = structuredClone(circular); // works
circularCopy.self = circularCopy;
// structuredEqual(circular, circularCopy) — JSON.stringify still throws on circular refs
// For truly circular graphs, use Lodash _.isEqual or the WeakSet-based deepEqualSafe above

Key caveat: the final JSON.stringify step still throws on circular references even after structuredClone. If your data has cycles, stick with Lodash or the deepEqualSafe function from Method 3. The hybrid shines for objects with Date, Map, and Set properties where you do not want to add a library dependency.

Handling Edge Cases: Dates, NaN, +0/-0, Functions, undefined

JavaScript has several notorious equality gotchas. Each comparison method handles them differently, and misunderstanding them causes subtle bugs that are hard to reproduce.

NaN

NaN === NaN; // false — NaN is not equal to itself
Number.isNaN(NaN); // true — correct check

// In comparison context:
// JSON.stringify: NaN becomes null — two objects with NaN values appear equal
// because both serialize to null, but that's wrong if they actually differ
// Lodash _.isEqual: treats NaN as self-equal (correct for comparison purposes)
// fast-deep-equal: NaN === NaN returns false in default build

+0 and -0

+0 === -0; // true — unhelpful if you care about sign
Object.is(+0, -0); // false — distinguishes them

// Lodash _.isEqual treats +0 and -0 as NOT equal
// JSON.stringify: both serialize to 0, so they appear equal
// fast-deep-equal: uses === so +0 and -0 appear equal

undefined in objects

const a = { x: 1, y: undefined };
const b = { x: 1 };

// JSON.stringify drops undefined keys entirely:
JSON.stringify(a); // '{"x":1}'  — y is gone
JSON.stringify(b); // '{"x":1}'  — same result!
// So JSON.stringify says a equals b — which may not be what you want

// Lodash _.isEqual distinguishes them:
// isEqual(a, b) returns false — y: undefined is present in a but absent in b

// Object.keys: Object.keys(a) includes 'y', so shallowEqual catches it

Functions and Symbols

const a = { fn: () => 42 };
const b = { fn: () => 42 };

// JSON.stringify: functions are dropped, both serialize to '{}'  — appear equal
// Lodash _.isEqual: functions compared by reference — returns false
// fast-deep-equal: functions compared by reference — returns false

// Symbol keys are always ignored by JSON.stringify and Object.keys.
// Use Object.getOwnPropertySymbols() if Symbol keys matter for your comparison.
Equality Edge Cases — Gotchas Grid Gotcha JSON.stringify Lodash _.isEqual fast-deep-equal NaN NaN !== NaN in JS NaN becomes null wrongly appears equal NaN == NaN handled correctly NaN !== NaN default build fails +0 / -0 +0 === -0 is true both serialize to 0 sign lost, appear equal +0 != -0 distinguishes signs +0 == -0 uses ===, signs match undefined key vs. missing key key dropped silently {x:undefined} == {} present != absent correctly different present != absent correctly different Handled correctly Gotcha / incorrect result

Real-World Debugging: Comparing API Responses with Visual Diff

Code-based comparison methods tell you whether two objects are equal. They do not tell you where they differ or why — and that is exactly what you need when debugging a production issue at speed.

Here is a scenario that plays out regularly. Your team integrates with Stripe's API. A payment webhook handler that worked fine for months starts silently failing after a library upgrade. You add a log and dump two consecutive API responses — one that triggered the correct flow and one that did not. They are both large JSON objects, about 80 properties deep. Your isEqual call returns false, but scanning the console output is not practical.

The specific change: Stripe deprecated the top-level last4 field and moved it to card.last4 inside the charge object. Your handler still reads charge.last4 and gets undefined. The new response has charge.card.last4.

Workflow with Diff Checker Pro:

  1. Copy the old API response JSON, paste it into the left panel of Diff Checker Pro.
  2. Copy the new response, paste it into the right panel.
  3. Click Normalize — this sorts all JSON keys alphabetically and recursively, eliminating noise from key-order changes that Stripe may have also introduced in the same release.
  4. Switch to Side-by-side view. The Monaco DiffEditor (the same engine that powers VS Code's diff view) highlights every changed line.
  5. Spot the difference in about 10 seconds: last4: "4242" has disappeared from the root level, and a new card object has appeared containing last4: "4242".

You can also drag and drop two saved JSON files directly onto the panels, or use the file picker for responses saved from Postman or your logging system. The tool supports JSON files up to 50 MB (10 MB recommended for best performance). If the diff is ambiguous, the AI Summary feature (using your own OpenAI API key — data goes to OpenAI, not stored locally) can describe the semantic changes in plain English.

For a deeper look at JSON-specific comparison techniques and tooling, the guide on comparing JSON objects online covers both programmatic and visual approaches in detail. If your API responses are paginated arrays, the dedicated JSON list and array guide walks through parsing, comparing, and validating lists of JSON objects across JS, Python, and Java.

Debugging API Responses — Visual Diff Workflow API v1 response JSON API v2 response JSON Paste into both panels Left / Right Normalize sort all keys alphabetically Side-by-side Monaco diff highlights changes Spot diff last4 moved to card.last4 Diff Checker Pro identifies the renamed field in seconds — no manual JSON scanning needed Update handler: read charge.card.last4 instead of charge.last4

Performance Benchmarks: Which Method Wins

Performance matters most in two scenarios: React rendering (comparing props or state on every render cycle) and high-throughput Node.js services (comparing request/response objects in middleware). The numbers below are representative of typical benchmark results across v8-based runtimes — run your own benchmarks on your actual data shapes before making performance-critical decisions.

Method Small Object (10 keys) Large Object (100+ nested keys) Notes
fast-deep-equal Fastest Fastest Minimal overhead; no circular ref or NaN equality handling
JSON.stringify (unsorted) Fast Moderate String allocation + serialization cost; unstable with key order
Object.keys shallow Fast N/A (shallow only) Best for flat objects; no nesting cost
Custom recursive Moderate Slow Depends heavily on implementation quality
Lodash _.isEqual Slow Slow Comprehensive; correctness cost justified for production code
structuredClone hybrid Slowest Slowest Clone cost dominates; use only when Date/Map/Set handling needed

In practice, Lodash's correctness guarantees (handling NaN, +0/-0, circular refs) often outweigh the performance difference for most applications comparing API responses or form state. Published benchmarks show fast-deep-equal at ~3-5M ops/sec significantly exceeds Lodash's ~36k ops/sec, but this matters only in hot paths processing tens of thousands of comparisons per second. For typical web apps, Lodash is safer; for high-throughput services with controlled data shapes, profile fast-deep-equal first on your actual data.

One common pattern worth avoiding: using JSON.stringify to compare two objects javascript developers pass to React useMemo or useEffect dependency arrays. The key-order instability means you may get spurious re-renders even when the data has not changed. fast-deep-equal or a stable shallow comparison is the better choice for React performance work, and the Node.js util.isDeepStrictEqual reference is worth a look on the server side.

Frequently Asked Questions

Is JSON.stringify reliable for comparing JavaScript objects?

JSON.stringify is reliable only for plain objects with string, number, boolean, null, array, and nested plain object values — and only when you control key insertion order or sort keys before stringifying. It silently drops undefined values, functions, and Symbol keys. It throws on circular references. It converts Date objects to ISO strings, which means two Date objects representing the same moment will compare equal, but a Date and a matching ISO string will also incorrectly compare equal. For production code that needs to handle any of these cases, use Lodash _.isEqual or fast-deep-equal instead.

What's the fastest deep equality library for JavaScript?

fast-deep-equal (v3.1.3) is typically faster than Lodash _.isEqual in benchmarks. It handles nested objects, arrays, Date, RegExp, Map, and Set. The trade-off is that the default export does not handle circular references and does not treat NaN as self-equal — use the 'es6' variant for Map/Set support or 'react-fast-compare' if you also need React prop comparison. For API response comparison where you control the data shape, fast-deep-equal is worth profiling first.

How do I compare objects with Date or RegExp properties?

JSON.stringify cannot reliably compare Date or RegExp properties — RegExp objects serialize to '{}' and two different Date instances representing the same moment will appear equal even if they are separate objects. For objects containing Date or RegExp, use Lodash _.isEqual (which has explicit handlers for both types), fast-deep-equal (which compares Date by valueOf() and RegExp by source and flags), or write explicit type checks in a custom recursive comparator. Never rely on == or === for Date or RegExp equality.

Should I write my own deep equal function or use Lodash?

Use Lodash _.isEqual or fast-deep-equal unless you have a compelling reason not to. Both are battle-tested, handle edge cases like NaN, +0/-0, Date, RegExp, Map, Set, and circular references that a naive hand-rolled function will miss. Writing your own is a worthwhile learning exercise, but production code deserves a maintained library. If bundle size is a concern, import just the isEqual function from lodash-es (tree-shakeable) or use fast-deep-equal which is 800 bytes minified.

How do I compare two API response objects for debugging?

For quick debugging, JSON.stringify with null and 2-space indent in console.log is fine to eyeball small objects. For serious debugging — especially when API responses are large, nested, or have changed between versions — paste both JSON payloads into a visual diff tool like Diff Checker Pro. Its Normalize button sorts all keys alphabetically before diffing, so you see only genuine value changes rather than key-order noise. The Monaco-based side-by-side view highlights exactly which fields changed, making it far faster than scanning console output.

Compare JSON Objects Visually — Free Chrome Extension

Skip the console.log marathon. Drop two JSON payloads into Diff Checker Pro and see every property change side by side, with smart key-order normalization.

Get Diff Checker Pro Free