JSON Diff: Compare Two JSON Objects in JavaScript

Last updated:

Comparing two JSON objects requires deep equality — JSON.stringify(a) === JSON.stringify(b) works for simple cases but fails on key order differences; fast-deep-equal and lodash _.isEqual() handle arbitrary nesting correctly in O(n) time. JSON Patch diff generation produces a list of RFC 6902 operations (add, remove, replace, move, copy) that transforms object A into object B — jsondiffpatch.diff(a, b) produces a compact delta, and jsondiffpatch.patch(a, delta) applies it. fast-json-patch: compare(a, b) generates a standard RFC 6902 patch array you can apply to any JSON Patch-compatible server. This guide covers deep equality with fast-deep-equal and lodash, JSON Patch diff generation with jsondiffpatch and fast-json-patch, semantic diff (ignore array order, ignore key order), structural diff visualization for UI, and three-way merge with json-merge-patch.

Deep Equality: fast-deep-equal vs JSON.stringify vs lodash

Deep equality checks whether two values are structurally identical at every level of nesting. Three approaches dominate in JavaScript: JSON.stringify comparison (fast but fragile), fast-deep-equal (fastest correct implementation), and lodash _.isEqual (most feature-complete). Choose based on your correctness requirements and whether the objects contain non-JSON types like Date, Map, Set, or RegExp.

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

const a = { user: { name: 'Alice', roles: ['admin', 'editor'] }, active: true };
const b = { user: { name: 'Alice', roles: ['admin', 'editor'] }, active: true };

// ── fast-deep-equal — O(n), handles nested objects/arrays ──────────
console.log(equal(a, b));         // true
console.log(equal(a, { ...a, active: false })); // false

// ── JSON.stringify — FAILS on key order differences ─────────────────
const x = { a: 1, b: 2 };
const y = { b: 2, a: 1 };
console.log(JSON.stringify(x) === JSON.stringify(y)); // false — WRONG
console.log(equal(x, y));                             // true  — correct

// ── JSON.stringify also fails with undefined / Date ─────────────────
const withDate = { created: new Date('2026-01-01') };
const withStr  = { created: '2026-01-01T00:00:00.000Z' };
console.log(JSON.stringify(withDate) === JSON.stringify(withStr)); // true — WRONG
// (Date serializes to its ISO string, so they look equal)
console.log(equal(withDate, withStr)); // false — correct (Date !== string)

// ── lodash _.isEqual — handles edge cases + circular refs ───────────
import _ from 'lodash';
console.log(_.isEqual(a, b));         // true
console.log(_.isEqual(x, y));         // true — key-order-independent

// ── Performance comparison (typical objects, 1M iterations) ──────────
// JSON.stringify: ~180ms (plus GC pressure from string allocations)
// fast-deep-equal: ~55ms  (fastest correct deep equality)
// lodash _.isEqual: ~120ms (slower, more feature-complete)

// ── When to use each ────────────────────────────────────────────────
// JSON.stringify: only for primitive-only objects where key order is guaranteed
//                (e.g., JSON.parse output from the same serializer)
// fast-deep-equal: default choice — fastest, handles all JSON types
// lodash _.isEqual: when you need circular reference safety or custom comparators
// fast-deep-equal/es6: when objects contain Map, Set, Date, ArrayBuffer

// ── Null and undefined edge cases ───────────────────────────────────
equal(null, null);          // true
equal(null, undefined);     // false
equal({ a: undefined }, {}); // false (fast-deep-equal)
_.isEqual({ a: undefined }, {}); // true (lodash — undefined props ignored)

The fast-deep-equal/es6 variant adds support for ES2015 types — Map, Set, Date, RegExp, and typed arrays (Uint8Array, etc.). Use it whenever your JSON might include these types. A subtle difference: lodash _.isEqual treats { a: undefined } and {} as equal (undefined own properties are ignored), while fast-deep-equaltreats them as different. Pick the behavior that matches your domain's definition of equality.

Generating JSON Patch Diffs with jsondiffpatch

jsondiffpatch computes a compact delta between two JSON objects using a longest-common-subsequence (LCS) algorithm for arrays and key-by-key comparison for objects. The delta format is proprietary — not RFC 6902 — but smaller and includes move operations for array elements. It is the go-to library when you need diff visualization or want to store compact change histories.

// npm install jsondiffpatch
import { create } from 'jsondiffpatch';

const jsondiffpatch = create({
  // Object identity for array elements (avoids treating moves as add+remove)
  objectHash: (obj) => obj.id ?? JSON.stringify(obj),
  arrays: { detectMove: true },
  textDiff: { minLength: 60 }, // use text diff only for strings > 60 chars
});

const left = {
  name: 'Alice',
  age: 30,
  roles: ['admin', 'editor'],
  address: { city: 'Paris', zip: '75001' },
};

const right = {
  name: 'Alice',
  age: 31,                        // changed
  roles: ['admin', 'viewer'],     // 'editor' replaced with 'viewer'
  address: { city: 'Lyon', zip: '69001' }, // both fields changed
  email: 'alice@example.com',     // added
};

// ── diff() returns a delta (undefined if no differences) ────────────
const delta = jsondiffpatch.diff(left, right);
console.log(JSON.stringify(delta, null, 2));
// {
//   "age":     [30, 31],                     // [old, new] = modified
//   "roles":   { "1": ["editor","viewer"] }, // index 1 modified
//   "address": {
//     "city": ["Paris", "Lyon"],
//     "zip":  ["75001", "69001"]
//   },
//   "email":   ["alice@example.com"]         // [new] = added
// }
// Fields absent from delta were not changed (name)

// ── patch(): apply delta to produce the right side ──────────────────
import { clone } from 'jsondiffpatch';
const patched = jsondiffpatch.patch(clone(left), delta);
console.log(JSON.stringify(patched) === JSON.stringify(right)); // true

// ── unpatch(): reverse a patch to recover the left side ─────────────
const unpatched = jsondiffpatch.unpatch(clone(right), delta);
console.log(JSON.stringify(unpatched) === JSON.stringify(left)); // true

// ── Delta encoding conventions ───────────────────────────────────────
// [newValue]            → added (array with one element)
// [oldValue, newValue]  → modified
// [oldValue, 0, 0]      → deleted
// [textDiff, 0, 2]      → text diff (long strings only, unified diff format)
// { _t: 'a', ... }      → array diff (LCS-based)

// ── No diff? Returns undefined ───────────────────────────────────────
const same = jsondiffpatch.diff(left, left);
console.log(same); // undefined — objects are identical

The objectHash option is critical for arrays of objects — without it, jsondiffpatch compares array elements by position, so moving an element appears as a series of add/remove operations. With objectHash returning a unique identifier (like obj.id), the library recognizes the same object at a different position and generates a compact move operation. Always configure objectHash when diffing arrays of domain objects.

fast-json-patch: RFC 6902 Diff and Apply

fast-json-patch generates and applies standard RFC 6902 JSON Patch documents — interoperable with any server or client that understands the spec. The compare(a, b) function produces an array of operation objects; applyPatch(doc, patch) applies them. Use this library when patch interoperability across systems matters.

// npm install fast-json-patch
import { compare, applyPatch, applyOperation, validate } from 'fast-json-patch';

const a = {
  name: 'Alice',
  age: 30,
  roles: ['admin', 'editor'],
  address: { city: 'Paris' },
};

const b = {
  name: 'Alice',
  age: 31,
  roles: ['admin', 'viewer'],
  address: { city: 'Lyon', country: 'France' },
  email: 'alice@example.com',
};

// ── compare(): generate RFC 6902 patch ──────────────────────────────
const patch = compare(a, b);
console.log(JSON.stringify(patch, null, 2));
// [
//   { "op": "replace", "path": "/age",            "value": 31 },
//   { "op": "replace", "path": "/roles/1",         "value": "viewer" },
//   { "op": "replace", "path": "/address/city",    "value": "Lyon" },
//   { "op": "add",     "path": "/address/country", "value": "France" },
//   { "op": "add",     "path": "/email",            "value": "alice@example.com" }
// ]

// RFC 6902 operations: add, remove, replace, move, copy, test

// ── applyPatch(): apply patch to a document ──────────────────────────
// WARNING: applyPatch mutates the document by default
const cloned = structuredClone(a);
const results = applyPatch(cloned, patch);
// results: array of { newDocument } for each operation
console.log(cloned); // now equals b

// ── Non-mutating apply (deep clone first) ────────────────────────────
const safe = applyPatch(structuredClone(a), patch).newDocument;
// Or pass applyPatch the document and let it return the new version:
const { newDocument } = applyPatch(structuredClone(a), patch, true, false);

// ── validate(): check a patch document before applying ───────────────
const errors = validate(patch, a);
if (errors && errors.length > 0) {
  console.error('Invalid patch:', errors);
} else {
  applyPatch(a, patch);
}

// ── Manually build RFC 6902 operations ──────────────────────────────
import { applyOperation } from 'fast-json-patch';

const doc = { user: { name: 'Alice' } };
applyOperation(doc, { op: 'add',     path: '/user/email', value: 'alice@example.com' });
applyOperation(doc, { op: 'replace', path: '/user/name',  value: 'Bob' });
applyOperation(doc, { op: 'remove',  path: '/user/email' });

// ── test operation: assert a value before applying ───────────────────
const conditionalPatch = [
  { op: 'test',    path: '/version', value: 3 },    // fails if version !== 3
  { op: 'replace', path: '/status',  value: 'published' },
];
// If 'test' fails, applyPatch throws a JsonPatchError

// ── Send patch over HTTP to a JSON Patch-compatible API ──────────────
await fetch('/api/users/123', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json-patch+json' },
  body: JSON.stringify(patch),
});

The RFC 6902 test operation is a powerful feature for optimistic concurrency — prepend a test operation asserting the current version number before your changes. If another client has already modified the document, the test fails and the entire patch is rejected atomically, preventing lost updates. This is the JSON Patch equivalent of a SQL WHERE version = N clause in an UPDATE statement.

Semantic Diff: Ignoring Array Order and Key Order

Structural diff compares objects exactly as they are. Semantic diff applies domain rules: ignore key insertion order, treat arrays as sets (order-independent), normalize certain fields before comparison (trim whitespace, lowercase strings, round floats), or exclude irrelevant fields (timestamps, internal IDs). Lodash _.isEqualWith is the most flexible tool for semantic equality.

import _ from 'lodash';

// ── Key order is irrelevant in all deep equality libraries ───────────
const a = { b: 2, a: 1 };
const b = { a: 1, b: 2 };
_.isEqual(a, b); // true — key order ignored automatically

// ── Treat arrays as sets (order-independent) ─────────────────────────
function setEqual(arrA: unknown[], arrB: unknown[]): boolean {
  if (arrA.length !== arrB.length) return false;
  // Sort by stable JSON representation before comparing
  const sort = (arr: unknown[]) =>
    [...arr].sort((x, y) =>
      JSON.stringify(x) < JSON.stringify(y) ? -1 : 1
    );
  return _.isEqual(sort(arrA), sort(arrB));
}

console.log(setEqual(['admin', 'editor'], ['editor', 'admin'])); // true
console.log(setEqual([1, 2, 3], [3, 1, 2]));                    // true

// ── _.isEqualWith customizer: plug in domain rules ───────────────────
function semanticEqual(a: unknown, b: unknown): boolean {
  return _.isEqualWith(a, b, (valA, valB) => {
    // Arrays: compare as sets (ignore order)
    if (Array.isArray(valA) && Array.isArray(valB)) {
      return setEqual(valA, valB);
    }
    // Dates: compare by timestamp value regardless of object identity
    if (valA instanceof Date && valB instanceof Date) {
      return valA.getTime() === valB.getTime();
    }
    // Strings: normalize whitespace before comparison
    if (typeof valA === 'string' && typeof valB === 'string') {
      return valA.trim().toLowerCase() === valB.trim().toLowerCase();
    }
    // Let lodash handle everything else
    return undefined;
  });
}

const x = { name: '  Alice ', roles: ['editor', 'admin'] };
const y = { name: 'alice',    roles: ['admin', 'editor'] };
console.log(semanticEqual(x, y)); // true

// ── Exclude fields from comparison ───────────────────────────────────
function diffIgnoring(a: object, b: object, ignoreKeys: string[]) {
  const strip = (obj: object) => _.omit(obj, ignoreKeys);
  return _.isEqual(strip(a), strip(b));
}

const user1 = { id: 1, name: 'Alice', updatedAt: '2026-01-01' };
const user2 = { id: 1, name: 'Alice', updatedAt: '2026-05-20' }; // different timestamp
console.log(diffIgnoring(user1, user2, ['updatedAt', 'createdAt'])); // true

// ── Deep diff with path reporting ────────────────────────────────────
function findDiffPaths(
  a: Record<string, unknown>,
  b: Record<string, unknown>,
  path = ''
): string[] {
  const paths: string[] = [];
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
  for (const key of keys) {
    const fullPath = path ? `${path}/${key}` : key;
    if (!_.isEqual(a[key], b[key])) {
      if (
        typeof a[key] === 'object' && a[key] !== null &&
        typeof b[key] === 'object' && b[key] !== null &&
        !Array.isArray(a[key])
      ) {
        paths.push(...findDiffPaths(
          a[key] as Record<string, unknown>,
          b[key] as Record<string, unknown>,
          fullPath
        ));
      } else {
        paths.push(fullPath);
      }
    }
  }
  return paths;
}

const changedPaths = findDiffPaths(
  { user: { name: 'Alice', age: 30 } },
  { user: { name: 'Bob',   age: 30 } }
);
console.log(changedPaths); // ['user/name']

Semantic diff is particularly important when comparing API responses across versions — field order can change between serializers, arrays may be sorted differently on different runs, and timestamps will always differ. Build semantic equality helpers early and reuse them across your test suite to avoid brittle snapshot tests that fail on irrelevant field-order changes. See the JSON testing guide for applying semantic equality in snapshot tests.

Structural Diff Visualization for UIs

Showing users what changed between two JSON values — config versions, API responses, form submissions — requires rendering a diff with color-coded additions, removals, and modifications. jsondiffpatch's HTML formatter provides a ready-made solution; for React apps, a few wrapper patterns make it drop-in usable.

// npm install jsondiffpatch
import { create, formatters } from 'jsondiffpatch';
import 'jsondiffpatch/dist/formatters-styles/html.css'; // in your CSS bundle

const jsondiffpatch = create({ objectHash: (obj) => obj.id ?? JSON.stringify(obj) });

// ── 1. Raw HTML formatter (browser / vanilla JS) ────────────────────
const left  = { version: 1, name: 'Config A', timeout: 30 };
const right = { version: 2, name: 'Config A', timeout: 60, retries: 3 };

const delta = jsondiffpatch.diff(left, right);
const html  = formatters.html.format(delta, left);
// html: annotated HTML string with .jsondiffpatch-added/deleted/modified classes

document.getElementById('diff-container')!.innerHTML = html ?? '<p>No changes</p>';

// ── 2. React component wrapper ───────────────────────────────────────
// components/JsonDiff.tsx
import { useEffect, useRef } from 'react';
import { create, formatters } from 'jsondiffpatch';

const differ = create({ objectHash: (obj: Record<string, unknown>) => String(obj.id) });

export function JsonDiff({
  left,
  right,
}: {
  left: unknown;
  right: unknown;
}) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    const delta = differ.diff(left, right);
    if (!delta) {
      ref.current.innerHTML = '<p class="text-gray-500">No changes detected.</p>';
      return;
    }
    ref.current.innerHTML = formatters.html.format(delta, left) ?? '';
  }, [left, right]);

  return <div ref={ref} className="json-diff-container font-mono text-sm" />;
}

// ── 3. Annotated console formatter (Node.js / CLI tools) ────────────
import { formatters } from 'jsondiffpatch';
const delta2 = jsondiffpatch.diff(left, right);
formatters.console.log(delta2);
// Prints colored +/- annotations to stdout

// ── 4. Custom renderer: iterate RFC 6902 patch in React ─────────────
import { compare } from 'fast-json-patch';

function PatchViewer({ before, after }: { before: object; after: object }) {
  const ops = compare(before, after);
  return (
    <ul className="font-mono text-sm space-y-1">
      {ops.map((op, i) => (
        <li
          key={i}
          className={
            op.op === 'add'    ? 'text-green-700' :
            op.op === 'remove' ? 'text-red-700'   :
            'text-yellow-700'
          }
        >
          <span className="font-bold uppercase mr-2">{op.op}</span>
          <span className="mr-2">{op.path}</span>
          {'value' in op && (
            <span>{JSON.stringify(op.value)}</span>
          )}
        </li>
      ))}
    </ul>
  );
}

For production diff UIs, the jsondiffpatch HTML formatter is the fastest path to a working feature — it handles nested objects, arrays, and text diffs with minimal code. For design-system-controlled styling, the custom RFC 6902 patch renderer gives full control over markup and classes. Either way, pair with a virtualized list if the diff can contain thousands of operations, to avoid rendering performance issues in the browser.

Three-Way Merge with a Common Ancestor

Two-way diff computes what changed between A and B. Three-way merge answers a harder question: given a common ancestor base, one side's changes (left), and another side's changes (right), what is the merged result — and where did both sides change the same field? This is the foundation of collaborative editing, config file merging, and optimistic concurrency.

// ── Approach 1: json-merge-patch (RFC 7396) — last-write-wins ────────
// npm install json-merge-patch
import mergePatch from 'json-merge-patch';

const base  = { name: 'Alice', age: 30, city: 'Paris' };
const patch = { age: 31, email: 'alice@example.com' }; // RFC 7396 merge patch

const merged = mergePatch.apply(base, patch);
// { name: 'Alice', age: 31, city: 'Paris', email: 'alice@example.com' }
// Setting a key to null in the patch removes it from the result

// Generate a merge patch from two versions:
const mergeDiff = mergePatch.generate(base, { ...base, age: 31, email: 'alice@example.com' });
// { age: 31, email: 'alice@example.com' }

// ── Approach 2: Manual three-way merge with conflict detection ────────
import { compare, applyPatch } from 'fast-json-patch';

type Conflict = { path: string; leftValue: unknown; rightValue: unknown };

function threeWayMerge(
  base: Record<string, unknown>,
  left: Record<string, unknown>,
  right: Record<string, unknown>
): { result: Record<string, unknown>; conflicts: Conflict[] } {
  const leftPatch  = compare(base, left);
  const rightPatch = compare(base, right);

  const leftPaths  = new Map(leftPatch.map(op => [op.path, op]));
  const rightPaths = new Map(rightPatch.map(op => [op.path, op]));

  const conflicts: Conflict[] = [];
  const mergedPatch = [...leftPatch]; // start with left changes

  for (const [path, rightOp] of rightPaths) {
    const leftOp = leftPaths.get(path);
    if (!leftOp) {
      // Only right changed this path — include it
      mergedPatch.push(rightOp);
    } else {
      // Both changed the same path — conflict!
      const leftVal  = 'value' in leftOp  ? leftOp.value  : undefined;
      const rightVal = 'value' in rightOp ? rightOp.value : undefined;
      if (!_.isEqual(leftVal, rightVal)) {
        conflicts.push({ path, leftValue: leftVal, rightValue: rightVal });
        // Resolution strategy: prefer left (or throw, or prompt user)
      }
    }
  }

  const result = structuredClone(base) as Record<string, unknown>;
  applyPatch(result, mergedPatch);
  return { result, conflicts };
}

const base2  = { title: 'Draft', status: 'review', tags: ['json'] };
const left2  = { title: 'Final', status: 'review', tags: ['json'] };      // changed title
const right2 = { title: 'Draft', status: 'published', tags: ['json', 'patch'] }; // changed status+tags

const { result, conflicts } = threeWayMerge(base2, left2, right2);
console.log(result);
// { title: 'Final', status: 'published', tags: ['json', 'patch'] }
console.log(conflicts); // [] — no conflicts (different paths changed)

// ── Approach 3: automerge (CRDT) — concurrent edits without conflicts ─
// npm install @automerge/automerge
import * as Automerge from '@automerge/automerge';

let doc1 = Automerge.from({ count: 0, items: ['a'] });
let doc2 = Automerge.clone(doc1);

doc1 = Automerge.change(doc1, d => { d.count = 1; });
doc2 = Automerge.change(doc2, d => { d.items.push('b'); });

const merged = Automerge.merge(doc1, doc2);
console.log(merged); // { count: 1, items: ['a', 'b'] } — both changes preserved

For simple configuration merging where last-write-wins is acceptable, json-merge-patch (RFC 7396) is the right tool — it is simple, well-specified, and implemented in every language. For collaborative documents where concurrent edits must be preserved without data loss, CRDTs like Automerge provide conflict-free merging at the cost of more complex data structures. The manual three-way merge with conflict detection sits in the middle: deterministic, transparent about conflicts, and compatible with RFC 6902 patch infrastructure. See the JSON merge guide for more merge patterns.

Testing with JSON Diffs: Snapshot Testing Patterns

JSON diff functions are invaluable in testing — use them for smarter snapshot assertions, partial matching, and change-set validation. Rather than comparing entire serialized objects with toEqual, diff-based assertions pinpoint exactly which fields changed, producing readable failure messages and tolerating irrelevant differences like timestamps.

import { compare } from 'fast-json-patch';
import equal from 'fast-deep-equal';
import { create } from 'jsondiffpatch';

const jsondiffpatch = create({ objectHash: (obj) => obj.id ?? JSON.stringify(obj) });

// ── 1. Assert only specific fields changed ───────────────────────────
function expectOnlyChanged(
  before: object,
  after: object,
  allowedPaths: string[]
) {
  const patch = compare(before, after);
  const unexpectedChanges = patch.filter(op => !allowedPaths.includes(op.path));
  if (unexpectedChanges.length > 0) {
    throw new Error(
      `Unexpected changes: ${JSON.stringify(unexpectedChanges, null, 2)}`
    );
  }
}

test('updateAge only changes /age', () => {
  const before = { name: 'Alice', age: 30, updatedAt: '2026-01-01' };
  const after  = updateAge(before, 31);
  expectOnlyChanged(before, after, ['/age', '/updatedAt']); // updatedAt allowed to change
});

// ── 2. Semantic snapshot — ignore volatile fields ─────────────────────
import _ from 'lodash';

function stableSnapshot(obj: object, ignoreKeys = ['id', 'createdAt', 'updatedAt']) {
  return JSON.parse(JSON.stringify(obj, (key, val) =>
    ignoreKeys.includes(key) ? undefined : val
  ));
}

test('createUser returns expected shape', async () => {
  const user = await createUser({ name: 'Alice', email: 'alice@example.com' });
  // Don't snapshot the full user (id and timestamps differ per run)
  expect(stableSnapshot(user)).toMatchSnapshot();
});

// ── 3. Diff-based error messages in custom matchers ──────────────────
// Jest custom matcher:
expect.extend({
  toDeepEqualWithDiff(received: unknown, expected: unknown) {
    if (equal(received, expected)) {
      return { pass: true, message: () => 'Objects are equal' };
    }
    const delta = jsondiffpatch.diff(expected, received);
    return {
      pass: false,
      message: () =>
        `Objects differ:\n${JSON.stringify(delta, null, 2)}`,
    };
  },
});

test('API response matches expected shape', async () => {
  const response = await fetchUser(1);
  expect(response).toDeepEqualWithDiff({
    id: 1, name: 'Alice', roles: ['admin'],
  });
  // Failure shows exactly what changed, not the full objects
});

// ── 4. Property-based testing: round-trip patch application ──────────
import fc from 'fast-check';

test('compare + applyPatch round-trips any JSON object', () => {
  fc.assert(fc.property(
    fc.object(),  // random JSON object (left)
    fc.object(),  // random JSON object (right)
    (a, b) => {
      const patch   = compare(a, b);
      const applied = applyPatch(structuredClone(a), patch).newDocument;
      return equal(applied, b);
    }
  ));
});

The expectOnlyChanged helper is particularly useful for testing mutation functions — it asserts that a function only touches the fields it is supposed to, preventing accidental side effects on unrelated fields. Combine it with property-based testing (fast-check) to verify that your patch generation and application round-trips correctly for arbitrary inputs. For more patterns on validating JSON in tests, see the JSON testing guide, and for transform pipelines tested with diffs, see JSON transform.

Key Terms

deep equality
A comparison that checks whether two values are structurally identical at every level of nesting — not just whether they are the same object reference (referential equality) or produce the same string (serialization equality). Deep equality traverses nested objects and arrays recursively, comparing each leaf value. Libraries like fast-deep-equal and lodash _.isEqual implement deep equality in O(n) time where n is the total number of values. Key-order differences ({ a:1, b:2 } vs { b:2, a:1 }) are irrelevant to deep equality — both objects are deeply equal because they have the same key-value pairs. Contrast with JSON.stringify comparison, which is sensitive to key insertion order.
JSON Patch (RFC 6902)
An IETF standard (RFC 6902) defining a JSON document format for describing changes to another JSON document. A JSON Patch document is a JSON array of operation objects, each with an op field (add, remove, replace, move, copy, or test), a path field (JSON Pointer — RFC 6901), and optionally a value field. JSON Patch is the basis for partial updates in REST APIs — send a PATCH request with Content-Type: application/json-patch+json. The test operation enables optimistic concurrency by asserting a value must equal a specific state before the remaining operations are applied.
jsondiffpatch delta
The proprietary diff format produced by the jsondiffpatch library. A delta is a JSON object where keys correspond to changed fields. Modified values are represented as a two-element array [oldValue, newValue]; added values as a one-element array [newValue]; deleted values as [oldValue, 0, 0]. Array diffs are marked with a special _t: 'a' field and use LCS (longest common subsequence) to detect moves as well as additions and deletions. The delta format is not interoperable with RFC 6902 JSON Patch — it is specific to jsondiffpatch and is smaller than a full RFC 6902 patch for typical changes.
fast-deep-equal
A minimalist JavaScript library providing the fastest correct deep equality function. The default export handles all JSON-compatible types (objects, arrays, strings, numbers, booleans, null). The fast-deep-equal/es6 variant also handles Date, RegExp, Map, Set, and typed arrays. At roughly 55ms for one million comparisons of typical objects, it outperforms lodash _.isEqual (which is about 2x slower). It does not handle circular references — for circular reference safety, use lodash. Install with npm install fast-deep-equal; import with import equal from 'fast-deep-equal'.
semantic diff
A diff that applies domain-specific equality rules rather than strict structural equality. Examples: treating arrays as unordered sets so ['a', 'b'] equals ['b', 'a']; ignoring volatile fields like timestamps, internal IDs, or version numbers; normalizing string values (trimming whitespace, lowercasing) before comparison; or rounding floating-point numbers to a specified precision. Semantic diffs are implemented with lodash _.isEqualWith(a, b, customizer), where the customizer function returns the comparison result for specific value types and undefined to fall back to default behavior for everything else.
three-way merge
A merge strategy that uses a common ancestor (base) to resolve changes from two diverged versions (left and right). Changes are computed as two diffs: diff(base, left) and diff(base, right). If a path is changed by only one side, that change is applied to the result. If a path is changed by both sides with different values, it is a conflict that requires resolution — either by rule (prefer left, prefer right, use newer timestamp) or by prompting the user. Three-way merge is the algorithm behind Git merges, collaborative document editors, and optimistic concurrency control. It is more precise than a two-way overwrite because it preserves independent changes from both sides.

FAQ

How do I compare two JSON objects in JavaScript?

Use fast-deep-equal for the fastest correct comparison: import equal from 'fast-deep-equal'; equal(a, b) returns true if the objects are structurally identical, regardless of key insertion order, and handles nested objects, arrays, and all JSON-compatible types correctly. For simple cases with known key order and no special types, JSON.stringify(a) === JSON.stringify(b) works but silently produces wrong results when key order differs between the two objects. Lodash _.isEqual(a, b) is the most feature-complete option — it handles circular references and accepts a custom comparator via _.isEqualWith. For comparing objects that may contain Date, Map, or Set instances, use fast-deep-equal/es6.

What is the difference between JSON.stringify comparison and deep equality?

JSON.stringify(a) === JSON.stringify(b) is a text comparison — it serializes both objects to strings and compares the characters. It fails in three key ways: (1) key order sensitivity — { a:1, b:2 } and { b:2, a:1 } serialize to different strings even though they represent the same data; (2) type normalization — Date objects serialize to their ISO string representation, making a Date and its ISO string appear equal when they are not; (3) value suppression — undefined, Function, and Symbol values are dropped by JSON.stringify, so objects differing only in those properties appear equal. Deep equality algorithms (fast-deep-equal, lodash) traverse the object graph structurally, comparing values independently of serialization order, making them correct for all JSON-compatible inputs.

How do I generate a JSON Patch diff?

Two main options: (1) fast-json-patch: import { compare } from 'fast-json-patch'; const patch = compare(a, b); — returns a standard RFC 6902 array like [{'{"op":"replace","path":"/name","value":"Bob"}'}]. Apply it with applyPatch(clone, patch). (2) jsondiffpatch: const delta = jsondiffpatch.diff(a, b); — returns a compact proprietary delta (not RFC 6902). Apply it with jsondiffpatch.patch(clone(a), delta). Use fast-json-patch when you need interoperability with RFC 6902-compatible servers or clients (most JSON Patch HTTP APIs). Use jsondiffpatch when you want a smaller delta, array move detection, or the built-in HTML diff visualizer.

What is jsondiffpatch?

jsondiffpatch is a JavaScript library for computing the difference (delta) between two JSON values and applying or reversing that delta. Its diff(left, right) function produces a compact delta object using LCS (longest common subsequence) for arrays and key-by-key comparison for objects. The delta format encodes additions as [value], modifications as [oldValue, newValue], and deletions as [oldValue, 0, 0]. Unlike RFC 6902 JSON Patch, the delta is proprietary and not interoperable with other implementations. jsondiffpatch also includes an HTML formatter that renders diffs as annotated side-by-side HTML — making it the standard choice for building diff visualization UIs in browsers. Install with npm install jsondiffpatch.

How do I apply a JSON diff?

For RFC 6902 patches (fast-json-patch): import { applyPatch } from 'fast-json-patch'; applyPatch(document, patch) — this mutates the document in place. For safe non-mutating application: applyPatch(structuredClone(document), patch).newDocument. For jsondiffpatch deltas: jsondiffpatch.patch(structuredClone(original), delta) applies the delta and returns the patched value. To reverse a jsondiffpatch delta: jsondiffpatch.unpatch(structuredClone(right), delta) recovers the original left value. Always clone the document before applying to avoid unintended mutations. Use the test operation in RFC 6902 patches for optimistic concurrency — it throws JsonPatchError if the document was modified by another process since the patch was generated.

How do I ignore key order when comparing JSON?

Use fast-deep-equal or lodash _.isEqual — both are key-order-independent by design: equal({ a:1, b:2 }, { b:2, a:1 }) returns true. JSON.stringify comparison is the only common approach that is sensitive to key order. For custom semantic equality — ignoring timestamps, normalizing arrays before comparison, or skipping certain fields — use _.isEqualWith(a, b, customizer) where the customizer function returns the comparison result for specific value types and undefined to let lodash handle the rest. For array-as-set comparison (ignore element order), sort both arrays by a stable key before comparing: [...arr].sort((x,y) => JSON.stringify(x) < JSON.stringify(y) ? -1 : 1).

How do I visualize a JSON diff in a UI?

jsondiffpatch includes a built-in HTML formatter: compute the delta with jsondiffpatch.diff(left, right), then render it with formatters.html.format(delta, left) — this produces annotated HTML with .jsondiffpatch-added, .jsondiffpatch-deleted, and .jsondiffpatch-modified CSS classes. Include jsondiffpatch/dist/formatters-styles/html.css for the color styling. In React, inject the HTML into a div via dangerouslySetInnerHTML inside a useEffect. For a fully controlled React approach, iterate the RFC 6902 patch array from fast-json-patch compare(a, b) and render each operation (add/remove/replace) as a styled row with your own design system components.

How do I do a three-way JSON merge?

Three-way merge requires a common ancestor. Compute two diffs: leftPatch = compare(base, left) and rightPatch = compare(base, right). Merge both patch arrays — when a path appears in only one patch, include that operation; when both patches modify the same path with different values, that is a conflict. For last-write-wins merging without conflict detection, use json-merge-patch (RFC 7396): mergePatch.apply(base, patch) applies the patch and returns the merged result. For concurrent collaborative edits that must be preserved without data loss, use a CRDT library like @automerge/automerge, which handles concurrent changes to the same keys by tracking causality. See the JSON merge guide for RFC 7396 patterns and the JSON best practices guide for choosing between merge strategies.

Further reading and primary sources

  • fast-deep-equal on npmFastest deep equality library for JavaScript — handles all JSON types and ES2015 types (Map, Set, Date)
  • RFC 6902: JSON PatchIETF specification for the JSON Patch format — add, remove, replace, move, copy, and test operations
  • jsondiffpatch on GitHubjsondiffpatch library — diff generation, patch application, and HTML formatter for diff visualization
  • fast-json-patch on npmFast RFC 6902 JSON Patch implementation — compare(), applyPatch(), and validate() functions
  • RFC 7396: JSON Merge PatchIETF specification for JSON Merge Patch — simpler than RFC 6902, last-write-wins semantics