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.
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.
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:
typeChanged at this path. No recursion needed — a number turning into an object is one fact, not a subtree of noise.valueChanged.Everything else is recursion.
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:
deleted.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 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:
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.
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.
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”.
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.
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.
+/-.One computation, three presentations.
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.
Semantic JSON diff isn’t one clever algorithm. It’s a composition of boring ones:
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.