JSON Diff in JavaScript: Compare Two JSON Objects

Last updated:

Comparing two JSON objects in JavaScript goes beyond a simple === check — objects are compared by reference, not by value. The right approach depends on what you need: a boolean answer (are they equal?), a list of what changed (a diff), or a machine-applicable patch. This guide covers every technique from a one-liner stringify check through recursive custom functions, popular libraries, and RFC 6902 JSON Patch, with runnable code examples for each.

Quick Equality Check: JSON.stringify

The simplest way to check whether two JSON objects are equal is to serialize both and compare the strings. This works reliably when the objects come from the same source and key insertion order is guaranteed — for example, two objects parsed from JSON strings where the key order is deterministic.

const a = { name: 'Alice', age: 30 }
const b = { name: 'Alice', age: 30 }
const c = { age: 30, name: 'Alice' }   // same data, different key order

console.log(JSON.stringify(a) === JSON.stringify(b))  // true
console.log(JSON.stringify(a) === JSON.stringify(c))  // false ← key-order trap

// Safer: sort keys before stringifying
function stableStringify(obj: unknown): string {
  return JSON.stringify(obj, Object.keys(obj as object).sort())
}

console.log(stableStringify(a) === stableStringify(c))  // true

// But stableStringify also fails on nested objects — each level needs sorted keys
function deepStableStringify(val: unknown): string {
  if (Array.isArray(val)) {
    return '[' + val.map(deepStableStringify).join(',') + ']'
  }
  if (val !== null && typeof val === 'object') {
    const sorted = Object.keys(val as object)
      .sort()
      .map(k => JSON.stringify(k) + ':' + deepStableStringify((val as Record<string, unknown>)[k]))
    return '{' + sorted.join(',') + '}'
  }
  return JSON.stringify(val)
}

console.log(deepStableStringify(a) === deepStableStringify(c))  // true

The stringify approach cannot tell you what changed — it only answers yes or no. It also silently drops undefined values and cannot handle circular references. For a true diff, you need a recursive approach.

Recursive Deep-Diff Function (No Dependencies)

A custom recursive diff function gives you full control over the output format and avoids any third-party dependency. The algorithm visits every key in both objects and classifies each difference.

type DiffEntry =
  | { type: 'added';   path: string; value: unknown }
  | { type: 'removed'; path: string; value: unknown }
  | { type: 'changed'; path: string; oldValue: unknown; newValue: unknown }

function deepDiff(
  left: unknown,
  right: unknown,
  path = '',
  result: DiffEntry[] = [],
): DiffEntry[] {
  // Same reference or primitive equality — no diff
  if (left === right) return result

  // Both are non-null objects (includes arrays)
  if (
    left !== null && right !== null &&
    typeof left === 'object' && typeof right === 'object'
  ) {
    const leftKeys  = Object.keys(left  as object)
    const rightKeys = Object.keys(right as object)
    const allKeys   = new Set([...leftKeys, ...rightKeys])

    for (const key of allKeys) {
      const childPath = path ? `${path}.${key}` : key
      const lVal = (left  as Record<string, unknown>)[key]
      const rVal = (right as Record<string, unknown>)[key]

      if (!(key in (left as object))) {
        result.push({ type: 'added',   path: childPath, value: rVal })
      } else if (!(key in (right as object))) {
        result.push({ type: 'removed', path: childPath, value: lVal })
      } else {
        deepDiff(lVal, rVal, childPath, result)
      }
    }
    return result
  }

  // Primitive values that differ
  result.push({ type: 'changed', path: path || '(root)', oldValue: left, newValue: right })
  return result
}

// Usage
const original = { user: { name: 'Alice', role: 'admin' }, score: 10 }
const updated  = { user: { name: 'Alice', role: 'editor' }, score: 10, tag: 'vip' }

const diff = deepDiff(original, updated)
console.log(diff)
// [
//   { type: 'changed', path: 'user.role', oldValue: 'admin', newValue: 'editor' },
//   { type: 'added',   path: 'tag',       value: 'vip' },
// ]

Using the deep-diff Library

The deep-diff npm package is the most widely adopted JSON diff library for JavaScript. It returns a rich array of Change objects and also supports applying and reverting diffs.

npm install deep-diff
import { diff, applyChange, revertChange } from 'deep-diff'

const original = {
  name: 'Alice',
  address: { city: 'Boston', zip: '02101' },
  tags: ['admin', 'user'],
}

const updated = {
  name: 'Alice',
  address: { city: 'Cambridge', zip: '02139' },
  tags: ['admin', 'user', 'beta'],
  score: 99,
}

const changes = diff(original, updated)
// changes is an array of Change objects, each with:
//   kind: 'N' | 'D' | 'E' | 'A'
//   path: string[]     — key path to the changed location
//   lhs:  unknown      — original value (left-hand side)
//   rhs:  unknown      — new value (right-hand side)
//   index: number      — (A only) array index
//   item:  Change      — (A only) the inner change

for (const change of changes ?? []) {
  const pathStr = (change.path ?? []).join('.')
  switch (change.kind) {
    case 'E': console.log(`CHANGED  ${pathStr}: ${change.lhs} → ${change.rhs}`); break
    case 'N': console.log(`ADDED    ${pathStr}: ${JSON.stringify(change.rhs)}`);   break
    case 'D': console.log(`REMOVED  ${pathStr}: ${JSON.stringify(change.lhs)}`);   break
    case 'A': console.log(`ARRAY[${change.index}] ${pathStr}: kind=${change.item.kind}`); break
  }
}
// CHANGED  address.city: Boston → Cambridge
// CHANGED  address.zip: 02101 → 02139
// ARRAY[2] tags: kind=N
// ADDED    score: 99

// Apply changes to a copy of original to produce updated
import { structuredClone } from 'node:v8' // Node 17+, or use globalThis.structuredClone
const copy = structuredClone(original)
for (const change of changes ?? []) {
  applyChange(copy, updated, change)
}
console.log(copy)  // identical to updated

microdiff: Tiny Zero-Dependency Diff

microdiff is under 1 KB minified and gzipped, has no dependencies, and returns a simple flat array of changes. It is the best choice when bundle size matters — in browser bundles, edge functions, or size-constrained environments.

npm install microdiff
import diff from 'microdiff'

const a = { user: { name: 'Bob', age: 25 }, active: true }
const b = { user: { name: 'Bob', age: 26 }, active: true, plan: 'pro' }

const changes = diff(a, b)
console.log(changes)
// [
//   { type: 'CHANGE', path: ['user', 'age'], oldValue: 25, value: 26 },
//   { type: 'CREATE', path: ['plan'],         oldValue: undefined, value: 'pro' },
// ]

// Change types: 'CREATE' | 'REMOVE' | 'CHANGE'
// Each entry has: type, path (string[]), oldValue, value

// Check if there are any differences
const hasChanges = diff(a, b).length > 0
console.log(hasChanges)  // true

// Get only the changed paths
const changedPaths = diff(a, b).map(d => d.path.join('.'))
console.log(changedPaths)  // ['user.age', 'plan']

// Performance note: microdiff is ~3-5x faster than deep-diff on large objects
// because it avoids object instantiation overhead for each change entry

JSON Patch (RFC 6902) with fast-json-patch

JSON Patch (RFC 6902) represents changes as a JSON array of operation objects. Unlike a diff that is only human-readable, a JSON Patch document is machine-applicable: send it over the wire and apply it to a replica to bring it up to date without transmitting the full document.

npm install fast-json-patch
import { compare, applyPatch, createPatch } from 'fast-json-patch'

const original = { name: 'Carol', role: 'viewer', tags: ['a', 'b'] }
const updated  = { name: 'Carol', role: 'editor', tags: ['a', 'b', 'c'], score: 5 }

// Generate a JSON Patch document
const patch = compare(original, updated)
console.log(JSON.stringify(patch, null, 2))
// [
//   { "op": "replace", "path": "/role",    "value": "editor" },
//   { "op": "add",     "path": "/tags/2",  "value": "c" },
//   { "op": "add",     "path": "/score",   "value": 5 }
// ]

// Apply the patch to a copy of original
const copy = structuredClone(original)
const result = applyPatch(copy, patch)
console.log(copy)  // identical to updated

// Validate a patch without applying it
import { validate } from 'fast-json-patch'
const errors = validate(patch, original)
console.log(errors)  // [] — no errors

// RFC 6902 operations:
// "add"     — add a value at path (or append to array)
// "remove"  — remove the value at path
// "replace" — replace the value at path
// "move"    — move value from one path to another
// "copy"    — copy value from one path to another
// "test"    — assert that path equals value (throws if not)

Diffing Arrays of Objects

When comparing two arrays of records — such as two snapshots of a database query result — the strategy depends on whether the arrays are ordered (position matters) or unordered (records are identified by a unique key).

// --- Ordered array diff (position-by-position) ---
function diffOrderedArrays<T>(
  left: T[],
  right: T[],
  itemDiff: (l: T, r: T) => unknown[],
): { index: number; changes: unknown[] }[] {
  const maxLen = Math.max(left.length, right.length)
  const results = []

  for (let i = 0; i < maxLen; i++) {
    if (i >= left.length)  { results.push({ index: i, changes: [{ type: 'added',   value: right[i] }] }); continue }
    if (i >= right.length) { results.push({ index: i, changes: [{ type: 'removed', value: left[i]  }] }); continue }
    const changes = itemDiff(left[i], right[i])
    if (changes.length) results.push({ index: i, changes })
  }
  return results
}

// --- Unordered array diff (keyed by ID) ---
type Record = { id: string; [key: string]: unknown }

function diffKeyedArrays(left: Record[], right: Record[]) {
  const leftMap  = new Map(left.map(r  => [r.id, r]))
  const rightMap = new Map(right.map(r => [r.id, r]))

  const added   = right.filter(r => !leftMap.has(r.id))
  const removed = left.filter(r  => !rightMap.has(r.id))
  const changed = left
    .filter(r => rightMap.has(r.id))
    .flatMap(r => {
      import diff from 'microdiff'  // or your diff fn
      const changes = diff(r, rightMap.get(r.id)!)
      return changes.length ? [{ id: r.id, changes }] : []
    })

  return { added, removed, changed }
}

// Example
const before = [
  { id: '1', name: 'Alice', score: 10 },
  { id: '2', name: 'Bob',   score: 20 },
]
const after = [
  { id: '1', name: 'Alice', score: 15 },  // score changed
  { id: '3', name: 'Carol', score: 30 },  // new record
  // id:2 removed
]

// Result: { added: [{id:'3',...}], removed: [{id:'2',...}], changed: [{id:'1', changes:[...]}] }

Visualizing Diffs in the Browser

A visual diff renders additions in green, deletions in red, and modifications in yellow — similar to a git diff view. You can build this from any diff library output combined with formatted JSON strings.

// React component: side-by-side JSON diff viewer
import diff from 'microdiff'
import { useMemo } from 'react'

interface JsonDiffViewerProps {
  original: unknown
  updated:  unknown
}

export function JsonDiffViewer({ original, updated }: JsonDiffViewerProps) {
  const changes = useMemo(() => diff(original as object, updated as object), [original, updated])

  // Build a Set of changed dot-paths for fast lookup
  const changedPaths = useMemo(
    () => new Set(changes.map(c => c.path.join('.'))),
    [changes],
  )

  // Line-by-line rendering with color coding
  function colorLine(line: string, lineIndex: number): string {
    // Heuristic: find which path this line corresponds to
    // (a full implementation would use a JSON AST; this is illustrative)
    const isAdded   = changes.some(c => c.type === 'CREATE' && JSON.stringify(c.value) === line.trim().replace(/,$/, '').split(': ')[1])
    const isRemoved = changes.some(c => c.type === 'REMOVE' && JSON.stringify(c.oldValue) === line.trim().replace(/,$/, '').split(': ')[1])
    if (isAdded)   return 'bg-green-100 text-green-900'
    if (isRemoved) return 'bg-red-100   text-red-900'
    return 'text-gray-800'
  }

  const originalLines = JSON.stringify(original, null, 2).split('\n')
  const updatedLines  = JSON.stringify(updated,  null, 2).split('\n')

  return (
    <div className="grid grid-cols-2 gap-4 font-mono text-sm">
      <div className="bg-gray-50 rounded p-3 overflow-x-auto">
        <p className="font-semibold text-gray-500 mb-2">Original</p>
        {originalLines.map((line, i) => (
          <div key={i} className="whitespace-pre">{line}</div>
        ))}
      </div>
      <div className="bg-gray-50 rounded p-3 overflow-x-auto">
        <p className="font-semibold text-gray-500 mb-2">Updated</p>
        {updatedLines.map((line, i) => (
          <div key={i} className="whitespace-pre">{line}</div>
        ))}
      </div>
      <div className="col-span-2">
        <p className="font-semibold text-gray-700 mb-2">Changes ({changes.length})</p>
        {changes.map((c, i) => (
          <div key={i} className="text-sm mb-1">
            <span className={c.type === 'CREATE' ? 'text-green-700' : c.type === 'REMOVE' ? 'text-red-700' : 'text-yellow-700'}>
              {c.type}
            </span>
            {' '}
            <code>{c.path.join('.')}</code>
            {c.type !== 'REMOVE' && <span className="text-gray-500"> = {JSON.stringify(c.value)}</span>}
            {c.type === 'REMOVE' && <span className="text-gray-500"> (was {JSON.stringify(c.oldValue)})</span>}
          </div>
        ))}
      </div>
    </div>
  )
}

// For a full-featured visual diff, consider:
// - jsondiffpatch (includes built-in HTML formatter)
// - react-json-view-compare
// - @microlink/react-json-view

Definitions

Deep Equality
A comparison that recursively checks every nested property and element for value equality, as opposed to reference equality (===), which only returns true when both sides point to the same object in memory. Two objects with identical structure and values are deeply equal but not reference-equal.
JSON Patch
A JSON document format (RFC 6902) that expresses a sequence of operations to apply to a target JSON document. Each operation is an object with op, path, and optionally value or from fields. Operations include add, remove, replace, move, copy, and test. A patch can be transmitted to a client to bring its local copy up to date without sending the full document.
deep-diff
An npm library that computes the structural differences between two JavaScript objects. It returns an array of Change objects, each describing a single difference with a kind code (N, D, E, or A), a path array, and left-hand and right-hand values. It also provides applyChange and revertChange helpers to programmatically apply or undo changes.
Structural Clone
A deep copy of an object produced by the structuredClone() global function (available natively in Node.js 17+ and all modern browsers). Unlike JSON.parse(JSON.stringify()), a structural clone preserves Date objects, Map, Set, ArrayBuffer, and handles circular references correctly. It is the recommended way to snapshot an object before mutating it for diff purposes.
RFC 6902
The IETF standard that defines the JSON Patch format. Published in April 2013, it specifies the six operation types, the JSON Pointer path syntax (RFC 6901) used to address locations within a document, and the semantics for applying patches atomically. Implementations that conform to RFC 6902 are interoperable — a patch generated by one library can be applied by another.

FAQ

Can I use JSON.stringify to compare two JSON objects in JavaScript?

Only in limited situations. JSON.stringify(a) === JSON.stringify(b) returns false for semantically identical objects whose keys were inserted in different orders. For a reliable equality check, use a recursive deep-equality function such as fast-deep-equal, or implement one yourself. The stringify approach is fine for quick sanity checks on objects you created programmatically with known key order, but should never be used in production diff or comparison logic.

What does the deep-diff library return?

It returns an array of Change objects. Each object has a kind field: N (new — property only in the right object), D (deleted — property only in the left object), E (edited — same key, different value), or A (array — an element within an array was added, removed, or changed). Each Change also carries a path array, lhs (original value), and rhs (new value). Array changes include an index and an inner item Change.

What is the difference between deep-diff and microdiff?

deep-diff produces richly annotated Change objects with kind codes, supports applying and reverting diffs programmatically, and has been battle-tested for years. microdiff is under 1 KB, zero-dependency, and returns simpler { type, path, oldValue, value } objects. microdiff is faster and leaner for pure diff detection; deep-diff is better when you need to programmatically apply or revert the changes, or when you need the richer metadata.

How do I write a recursive JSON diff function without a library?

Iterate all keys from both objects using a merged Set. For each key: if it exists only on the left, record a deletion; only on the right, record an addition; on both and values are objects, recurse with the extended path; on both with differing primitive values, record a change. Accumulate results in a flat array. The recursive function shown in the second section of this guide is a complete, production-ready starting point — about 30 lines of TypeScript.

What is JSON Patch (RFC 6902)?

JSON Patch is an IETF standard format for expressing changes to a JSON document as an array of operations. Each operation object has op (add, remove, replace, move, copy, test), path (a JSON Pointer string like /user/name), and an optional value. Unlike a plain diff, a JSON Patch can be transmitted over the network and applied to a replica — enabling efficient partial document updates instead of sending entire payloads.

How do I compare two arrays of JSON objects in JavaScript?

For ordered arrays, diff by index: zip both arrays, compare each pair, and handle differing lengths as insertions or deletions. For unordered arrays of records with unique IDs, build a Map from each array keyed by ID, then find IDs present only in the left (removed), only in the right (added), and in both (diff each pair for changes). This gives you clean added/removed/changed buckets regardless of record ordering.

How do I visualize a JSON diff in the browser?

The most practical approach: run your diff library, collect the changed paths, then render the JSON with JSON.stringify(obj, null, 2) split into lines. Apply CSS classes (green for additions, red for deletions, yellow for changes) based on which lines correspond to changed paths. For a ready-made solution, jsondiffpatch ships an HTML formatter and CSS, and React libraries like react-json-view-compare provide a drop-in component.

Is structuredClone better than JSON.parse(JSON.stringify()) for deep cloning before a diff?

Yes. structuredClone() handles types that JSON cannot: undefined, Date (preserved as a real Date, not a string), Map, Set, RegExp, ArrayBuffer, and circular references. JSON.parse(JSON.stringify()) silently drops undefined values, converts Date to ISO strings, and throws on circular references. Use structuredClone() whenever you need a safe deep copy before mutating an object for diff purposes — it is available natively in Node.js 17+ and all modern browsers.

Further reading and primary sources