Engineering

How JSON Diff Works Under the Hood: Trees, Paths, and a Few Classic Algorithms

Oct 3, 2026 · 6 min read

Click Compare on two JSON documents and every difference lights up in a second. But what actually happens in between? The short answer is “parse both documents into trees, then walk them” — but the interesting part is how many classic algorithms are hiding inside that walk.

Why not just diff the text?

The obvious first attempt is to pretty-print both documents with JSON.stringify and run a line-based diff, the way Git does. It falls apart immediately. Whitespace and line breaks are the first problem:

{"a":1,"b":2}
{
  "a": 1,
  "b": 2
}

Identical data, yet a text diff flags every line. Even after normalizing the formatting, key order breaks the approach:

{"a":1,"b":2}
{"b":2,"a":1}

Same document, different serialization — so JSON.stringify produces different strings and every line “changes”. And a line-based tool doesn’t understand JSON syntax at all: move "b": 2 to the top and a trailing comma moves with it, so a textual diff reports "a": 1 as modified even though only its comma changed:

  {
-     "a": 1
+     "b": 2,
+     "a": 1
-     "b": 2
  }

JSON is data, not text. So the diff has to happen on the data.

The core idea: compare trees, report paths

Once both inputs are parsed, the problem becomes tree comparison. The engine we use (@compare-json/core) produces not a list of changed lines, but a flat list of changed paths:

interface JSONValueDifference {
  pathSegments: string[];              // ['users', '[3]', 'email']
  pathString: string;                  // 'users[3].email'
  pathBelongsTo: 'base' | 'contrast' | 'both';
  diffType: 'added' | 'deleted' | 'valueChanged' | 'typeChanged';
}

That output shape drives everything else. The walk itself is a dispatch on value type:

  • Different types → report typeChanged at this path. No recursion needed — a number turning into an object is one fact, not a subtree of noise.
  • Same primitive type → compare directly; a mismatch is a valueChanged.
  • Both objects → compare key sets (below).
  • Both arrays → compare with one of three strategies (below).

Everything else is recursion.

Objects: matching keys, not lines

Because we report paths instead of rendering aligned text, object comparison doesn’t need sorting or two-pointer merging at all — key order is simply never consulted. The algorithm is closer to set intersection:

  1. Walk every key in the base object. If the key also exists on the contrast side, recurse into the two values. If not, the path is deleted.
  2. Walk the contrast object’s keys. Anything never matched is added.

Case-insensitive key matching is just a matching detail: build a Map from lowercased keys to real keys on the contrast side, and resolve each base key through it. The recursion doesn’t care.

Arrays: three strategies for three meanings of “same”

Arrays are where JSON diff gets genuinely interesting, because “these two arrays differ” is ambiguous. The engine offers three methods, each backed by a different classic algorithm:

By Index — the positional assumption

Pair up a[i] with b[i], recurse on each pair, and report any leftover tail as additions or deletions. This is the right choice when position is meaningful — rows of a table, coordinates, time series.

LCS — the minimal-edit assumption

When arrays are lists that gain or lose a few elements, index pairing produces garbage: inserting one element at the front shifts every index, and suddenly every pair “changed”. The fix is the classic longest common subsequence dynamic program:

// dp[i][j] = length of the LCS of a[0..i) and b[0..j)
if (deepEqual(a[i - 1], b[j - 1])) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);

Two details are worth noting. First, equality here isn’t === — elements can be objects, so the DP cell calls the whole comparator recursively and treats “produced zero differences” as equal. Second, the backtrack walk from dp[m][n] tells you directly which indexes matched: matched pairs are equal, unmatched base indexes are deleted, unmatched contrast indexes are added. One pass, and an insertion of one element is reported as exactly one insertion.

Unordered — the membership assumption

Sometimes order carries no meaning at all (tag lists, permission sets). Then comparison is multiset matching: for each base element, find any unmatched deep-equal element on the contrast side. What’s left over on each side is the diff. It’s O(n²) in the worst case, but for real-world config arrays that’s fine — and the result is exactly what a human means by “same list, different order”.

Small options, real consequences

A few normalizations sit at the value-comparison layer and quietly change what counts as a difference:

  • keyCaseInsensitive / valueCaseInsensitive — compare strings after lowercasing, so "Name" vs "name" or "TRUE" vs "true" stop being noise.
  • numericStringEqualsNumber — treats "8080" and 8080 as equal. APIs love smuggling numbers through strings; this option absorbs that.

Each is a few lines of code, but they’re the difference between a diff that’s technically correct and one that’s actually useful. You can toggle them in comparison options.

From a list of paths to something you can read

A flat list of path-addressed differences is a great interface for decoupling: the engine knows nothing about rendering, and the UI knows nothing about comparison.

  • The result sidebar groups differences by path and type — click an entry, jump to the spot.
  • The side-by-side Monaco view maps each difference back onto editor ranges for highlighting.
  • The git-diff view renders the same list as a unified, line-oriented summary for people who think in +/-.

One computation, three presentations.

Two production details you only learn the hard way

Big integers. JSON.parse silently corrupts integers beyond Number.MAX_SAFE_INTEGER — and JSON is full of 64-bit IDs. We parse with a bigint-safe parser so 9007199254740993 doesn’t become 9007199254740992 before the comparison even starts. A diff engine that mutates its input isn’t a diff engine.

Local by construction. Because the whole pipeline — parse, compare, render — is pure computation on parsed values, it runs entirely in your browser. There was never a reason for a server, so there isn’t one.

Summary

Semantic JSON diff isn’t one clever algorithm. It’s a composition of boring ones:

  • recursion over the value tree,
  • set-style key matching for objects,
  • index pairing, LCS with backtracking, or membership matching for arrays — depending on what “same” means for your data,
  • and a few targeted normalizations at the leaves.

The payoff is a diff that speaks in key paths (users[3].email changed) instead of line numbers — which is what you actually wanted to know.

Try it on your own data: compare two JSON files now.