Diffing looks like an algorithms exercise until you build one. Then it becomes a product problem: people do not want the mathematically minimal edit script, they want to see what changed, in the order they read it, without a ten-second freeze on a 3,000-line file.
This guide covers the two places diff shows up in web software — human-readable text comparison and machine-applicable JSON changes — and the practical thresholds where the textbook answer stops being the right answer. Almost all of it can run in the browser, which is worth remembering the next time someone is about to paste a config file full of credentials into a server-side diff service.
Why run diff in the browser at all
Three reasons, in order of how often they matter:
- Privacy. The things people diff are frequently the things they should not upload: environment files, keys, customer exports, unpublished copy, contract drafts. A diff that never leaves the tab removes an entire category of risk.
- Latency. A local diff on a 500-line document is single-digit milliseconds. The network round trip to a diff API is fifty to five hundred times that.
- Availability. No account, no quota, no “service unavailable” in the middle of an incident review.
The cost is that you own the performance envelope. That is the subject of most of this article.
The algorithm everyone quotes, and where it breaks
The classic formulation is the longest common subsequence. Build a table where cell (i, j) holds the LCS length of the first i lines of A and the first j lines of B, then walk backwards from the bottom-right corner to emit edits:
function lcsOps(a, b) {
const n = a.length, m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops = [];
let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ t: "=", v: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "-", v: a[i] }); i++; }
else { ops.push({ t: "+", v: b[j] }); j++; }
}
while (i < n) ops.push({ t: "-", v: a[i++] });
while (j < m) ops.push({ t: "+", v: b[j++] });
return ops;
}
This is correct, easy to read, and O(n·m) in both time and memory. The memory is what bites. Two 10,000-line files produce a 100-million-cell table; even at four bytes per cell that is 400 MB before any overhead, and a browser tab will not survive it politely. A 3,000-line pair is 9 million cells — survivable, but slow enough to be noticeable on a mid-range laptop.
The cheapest optimisation is to throw most of the input away
Real edits are local. When someone changes a paragraph in the middle of a document, the first and last thousand lines are byte-identical. Trim the common prefix and the common suffix before running the quadratic algorithm, then diff only the middle:
function trimCommon(a, b) {
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) start++;
let endA = a.length - 1, endB = b.length - 1;
while (endA >= start && endB >= start && a[endA] === b[endB]) { endA--; endB--; }
return {
prefix: a.slice(0, start),
midA: a.slice(start, endA + 1),
midB: b.slice(start, endB + 1),
suffix: a.slice(endA + 1),
};
}
For the editing-session case — which is the common case — this turns a 10,000×10,000 problem into a 40×40 one. It costs two linear passes and cannot change the result, so there is no reason not to do it first.
When the middle is still big: Myers, patience, or a limit
Myers' algorithm computes the same minimal edit script in O(ND) time and O(N) space, where D is the number of differences. When two versions of a file differ by twenty lines, D is small and Myers is effectively linear. When they differ by everything, it degrades toward the quadratic case. Git uses a Myers variant with a heuristic that abandons the search once the edit distance exceeds a bound; that heuristic is why git diff occasionally shows a slightly larger diff than strictly necessary in exchange for finishing.
Patience diff takes a different route: find lines that appear exactly once in both files, use those as anchors, and recurse between them. It produces far more readable hunks for code, because it lines up functions and blocks instead of matching braces and blank lines. If your product reviews source code, patience-style anchoring is usually worth more than optimality.
Whatever you choose, add an explicit ceiling. “Beyond 20,000 lines, show a structural summary instead of a line diff” is a better product than a spinner that ends in a crashed tab.
From lines to words
A line diff tells you that a paragraph changed. It does not tell you which three words changed, and for prose that is the whole point. The standard solution is two-pass: line diff first, then a word-level diff inside each changed region.
// Split into words, numbers, punctuation and whitespace, keeping every character.
const tokenise = (s) => s.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [];
Two rules keep word diff readable. Cap the size of any single intra-line comparison — a 4,000-character line without spaces (minified JSON, a base64 blob) should not enter the word-diff path at all — and collapse long runs of unchanged tokens with surrounding context, the way every code review tool does. Rendering 900 unchanged words to show a two-word change is worse than showing the line.
The output of the whole pipeline is a flat list, which is also the right shape for rendering:
[
{ "type": "equal", "text": "The retry window is " },
{ "type": "delete", "text": "five" },
{ "type": "insert", "text": "seven" },
{ "type": "equal", "text": " days by default." }
]
Performance budget, in numbers you can check
| Input size | Quadratic table | Practical approach |
|---|---|---|
| 200 lines | 40,000 cells | Plain LCS, no ceremony |
| 2,000 lines | 4 million cells | Trim first; LCS or Myers |
| 10,000 lines | 100 million cells | Trim, then Myers; or line-hash anchors |
| 100,000 lines | — | Do not line-diff. Summary, or server-side with git |
Run the computation in a Web Worker even when it is fast. The main thread owns layout and input; a 300 ms diff on it is a visibly stuck cursor, and on the main thread you cannot cancel it when the user keeps typing. A worker also gives you a clean place to enforce a timeout.
JSON is a different problem
Diffing two JSON documents as text is nearly useless: key order, indentation and number formatting produce enormous diffs for semantically identical values. The first step is always normalisation — parse, sort object keys, and serialise with fixed formatting — and the second step is to diff the structure rather than the characters.
Objects and arrays behave differently
Object keys have identity: {"a": 1} and {"a": 2} differ in one place. Arrays have order, but the order is often not meaningful. If you align arrays by index, inserting one item at the front rewrites every subsequent comparison. If you align them by an identity field — id, sku, slug — the same insertion becomes a single addition.
| Change | Index-aligned | Identity-aligned |
|---|---|---|
| Item inserted at position 0 | Every item shown as modified | One addition |
| Items reordered | Every item modified | Nothing, or one move |
| Item field changed | One item modified | One item modified |
Heuristics that work: use an identity key when every element is an object and they all share a unique scalar field; fall back to index alignment for scalars; and never silently drop an element because its identity is ambiguous.
JSON Patch (RFC 6902)
When the consumer is a program rather than a person, the output should be an applicable patch. RFC 6902 defines six operations over JSON Pointer paths:
[
{ "op": "replace", "path": "/retry/days", "value": 7 },
{ "op": "add", "path": "/retry/jitter", "value": true },
{ "op": "remove", "path": "/legacy/timeout" },
{ "op": "test", "path": "/version", "value": 3 }
]
Three details decide whether a patch is correct in production:
- Pointer escaping. A key containing
/or~must be escaped as~1and~0./a/bis two segments;/a~1bis one key nameda/b. This is the single most common bug in hand-written patches. - Array append. The path segment
-means “after the last element”. Use"/tags/-", not"/tags/3", unless you actually mean index 3. - Atomicity. The specification requires that if any operation fails, the patch is not applied at all. If you are implementing a patcher, evaluate against a copy and commit only on success; a half-applied patch is much worse than a rejected one.
The test operation is underused and quietly valuable. Putting {"op":"test","path":"/version","value":3} at the head of a patch turns it into a compare-and-swap: it fails loudly instead of overwriting a document that changed between read and write.
JSON Merge Patch (RFC 7386)
Merge patch is simpler and more limited: send the shape you want, and objects merge recursively.
{
"retry": { "days": 7, "jitter": true },
"legacy": { "timeout": null }
}
Here null means “delete this key”, which is the trap: there is no way to set a value to null. Arrays are replaced wholesale, never merged or appended. There is no test operation and no way to express a move.
| Need | Use |
|---|---|
| Apply a known, small change to a config object | Merge patch |
| Append to an array, move a value, or delete a key while keeping others | JSON Patch |
| Guard against concurrent edits | JSON Patch with test |
| Transport a document from a browser to a server | Neither — send the document and version it |
Test a patcher with one property
You do not need a hundred fixtures. One property catches most implementation errors:
For any two JSON values A and B,
apply(generate(A, B), A)must deep-equal B.
Generate random documents, mutate them randomly, round-trip them. This finds pointer-escaping bugs, array-append bugs and null-handling bugs within a few thousand iterations. Two caveats: compare with a function that treats 1 and 1.0 as equal but distinguishes them from "1", and remember that null is a legitimate value in both documents, which is exactly where merge patch and JSON Patch diverge.
Rendering a diff people can actually read
The last mile is presentation, and it is where most diff tools lose their audience.
- Do not rely on colour alone. Red and green are indistinguishable to roughly one in twelve men and vanish in greyscale printing. Prefix every changed line with
−or+, or give it a distinct left border. - Collapse, do not truncate. Show three lines of context and a clickable “42 unchanged lines” marker.
- Count what changed. “3 additions, 1 removal” is a faster briefing than any visual.
- Keep whitespace visible on request. A toggle for spaces and tabs prevents the most expensive kind of false negative: changes that look identical.
- Label for screen readers. A
delelement or anaria-label="removed"on the region makes the diff navigable without sight of the colours.
Try it without uploading anything
Pikkola Diff runs the pipeline described here entirely in the tab: line and word-level comparison, JSON key sorting and structural diff, and a compressed URL that carries the comparison without storing it anywhere. It is useful on its own, and it is also a quick way to see the difference between a line diff and a word diff on the same input before you wire one into your own product.
If you take one thing from this article, take the ordering: trim the common ends first, diff the line structure, then diff words inside the changed lines. Almost every performance and readability problem in a browser diff tool disappears when you refuse to run the expensive algorithm on the 95% of the input that did not change.