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.
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:
undefinedvalues — the key disappears entirely from the output- Functions — dropped without warning
- Symbol keys — ignored
NaNandInfinity— serialized asnull- 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.
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)
);
} 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
NaNas equal toNaNin the default build - No customization hooks (Lodash has
_.isEqualWithfor 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. 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:
- Copy the old API response JSON, paste it into the left panel of Diff Checker Pro.
- Copy the new response, paste it into the right panel.
- 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.
- Switch to Side-by-side view. The Monaco DiffEditor (the same engine that powers VS Code's diff view) highlights every changed line.
- Spot the difference in about 10 seconds:
last4: "4242"has disappeared from the root level, and a newcardobject has appeared containinglast4: "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.
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