Benchmarks
committed run: node v24.18.0 · AMD Ryzen 9 5900X 12-Core Processor · commit 9780e0e · jsdiff 5.2.2 · scripts/bench-vs-js.results.txt
Every number and chart on this page is generated from committed benchmark output using the same WebAssembly engine module as the site. That includes the cases where this engine loses; they are labeled exactly like the wins. The primary baseline is jsdiff (npm diff), the standard Myers-based diff library, so the comparison is against a real competitor and not a straw man; the repository's own JS reference (refdiff.mjs) is kept alongside it as an in-repo pipeline comparison. A checked-in script regenerates the page from the artifacts, and CI verifies that the displayed values match them.
The short version: against jsdiff, this engine is roughly at parity on typical diffs and dramatically faster on the pathological ones — the diffs that make a browser tab hang. That gap is not a micro-optimization; it is an algorithmic property, and it is the point of this page.
How it scales
The size-scaling family uses the same line-based content shape at growing sizes, with edits concentrated in a bounded zone. The chart plots three pipelines per case: the wasm engine, jsdiff, and the in-repo refdiff reference. The story is the two Myers implementations — jsdiff and the engine — tracking together: setting aside tiny-snippet (0.64x, where the wasm boundary floor costs more than the diff itself), the wasm pipeline stays within 1.01x to 1.43x of jsdiff across four orders of magnitude of input size. This is parity, and it is expected: both are Myers, so both scale the same way, and the engine's edge is the constant factor of running in wasm behind a compact boundary rather than a better asymptotic curve.
refdiff, a naive LCS, is the slower line over the mid range; the wasm pipeline overtakes it between tiny-snippet (360 chars, 0.73x) and small-10kb (10,389 chars, 1.58x). That crossover is a refdiff artifact, not the headline — the headline is parity with a real competitor, and the pathological cases where that parity breaks in the engine's favor are in the next section.
| case | what it is | input, chars | runs | refdiff | jsdiff | wasm (call + assembly) | wasm (M10 page path) | ratio vs jsdiff |
|---|---|---|---|---|---|---|---|---|
| tiny-snippet | the "Load example" pair every visitor sees (343 + 360 chars) | 343 + 360 | 50 | 0.049 ms | 0.043 ms | 0.067 ms | 0.055 ms | 0.64x |
| small-10kb | 330 lines / ~10 KB, 12 edited lines in one zone | 10,350 + 10,389 | 50 | 0.236 ms | 0.158 ms | 0.149 ms | 0.122 ms | 1.06x |
| mid-40kb | 1,300 lines / ~40 KB, 22 edited lines in one zone | 40,982 + 41,070 | 50 | 3.30 ms | 0.500 ms | 0.451 ms | 0.367 ms | 1.11x |
| large-150kb-sparse | 5,000 lines / 150 KB, 44 edited lines (the committed marketing fixture) | 152,579 + 152,846 | 50 | 5.89 ms | 1.85 ms | 1.52 ms | 1.23 ms | 1.21x |
| lines-10k | 10,000 lines / ~310 KB, 60 edited lines in one zone | 315,272 + 315,633 | 50 | 5.73 ms | 4.02 ms | 3.05 ms | 2.48 ms | 1.32x |
| large-1mb-sparse | 33,000 lines / ~1 MB, 72 edited lines in one zone | 1,038,890 + 1,039,396 | 25 | 10.26 ms | 13.09 ms | 12.94 ms | 7.97 ms | 1.01x |
| huge-5mb-sparse | 165,000 lines / ~5.4 MB, 89 edited lines in one zone (the M10 large-file target) | 5,263,685 + 5,264,324 | 7 | 80.58 ms | 72.53 ms | 62.97 ms | 39.36 ms | 1.15x |
| huge-13mb-sparse | 400,000 lines / ~13 MB, 97 edited lines in one zone | 12,900,754 + 12,901,554 | 5 | 200.51 ms | 247.93 ms | 173.46 ms | 113.38 ms | 1.43x |
The M10 page-path column models compute plus lazy row-model construction and one 60-row render window. It excludes the worker round trip used by the current site for larger inputs, so it is a pipeline breakdown rather than a live per-keystroke measurement. The comparison ratio is jsdiff total over the wasm full-assembly total, where both materialize every row.
The bounded worst case
Parity holds until the diff gets hard. jsdiff, like every textbook Myers implementation, runs in O(ND) time, where D is the number of edits between the two sides: cheap when the files are close, but the cost climbs with the edit distance and runs away when the files are far apart. This engine caps its search depth at a fixed bound (MAX_D = 2048 line edits after trimming, crates/diffwtf-core/src/myers.rs); past the bound it degrades deterministically to a delete-all/insert-all diff instead of searching further. That cap was added as a safety valve against pathological memory use, and it turns out to be the entire performance story: the engine's worst case is bounded, and jsdiff's is not.
Two cases in the run cross that line. On complete-rewrite — 3,000 vs 3,000 lines with nothing in common: both engines take their documented degradation path (reference LCS bailout, engine depth cap) and emit the same del-all/ins-all diff — jsdiff spends 1152.93 ms where the engine spends 21.75 ms, a 53.01x difference, and the two produce the identical output: both delete every left line then insert every right line. Same answer, 53.01x less time. On adversarial-repeats — 700 lines per side drawn from a 12-line pool, independently shuffled: maximal ambiguity; both engines produce minimal diffs that may differ in shape but must agree on counts — the gap is 8.82x (28.58 ms against 3.24 ms); here the two diffs are both minimal (they agree on the +398/-398 line counts) but pick a different equally-minimal shape, which is expected on maximally ambiguous input and is disclosed rather than papered over.
| case | input, chars | jsdiff | wasm (call + assembly) | ratio vs jsdiff | output vs jsdiff |
|---|---|---|---|---|---|
| complete-rewrite | 94,757 + 94,365 | 1152.93 ms | 21.75 ms | 53.01x | identical diff (delete-all then insert-all) |
| adversarial-repeats | 21,607 + 21,553 | 28.58 ms | 3.24 ms | 8.82x | equally minimal, different shape |
These are the diffs that make a browser tab hang: 1152.93 ms for a 3,000-line rewrite is well past the threshold where a keystroke feels broken. Nobody notices a 3 ms diff; everybody notices a one-second one. A bounded worst case is worth more here than a faster typical case, and it is why the two rows above — not the parity band above them — are the reason to compile the engine to wasm.
Where it loses
The same run includes the cases where the wasm pipeline is slower than jsdiff or not directly comparable, measured with the same methodology as everything else:
- Tiny inputs. On the "Load example" pair every visitor sees (343 + 360 chars), jsdiff wins: 0.043 ms against 0.067 ms for the wasm pipeline (0.64x). Both are a fraction of a millisecond and both are far below the threshold anyone can perceive; below this size the fixed cost of crossing the wasm boundary is larger than the diff itself, and it does not matter.
- Identical inputs. The large-150kb-identical case (1.16x vs jsdiff) measures a disclosed product shortcut, not engine speed: since M9 the engine short-circuits byte-identical inputs to a single Equal run. It is in the matrix because hiding a below-1x number would be spin; it must not be read as an engine measurement in either direction.
- Complete rewrites, against refdiff specifically. The complete-rewrite case is the engine's biggest win against jsdiff (53.01x, in the section above), but against the in-repo refdiff it is a loss: 21.75 ms against refdiff's 6.81 ms (0.31x). refdiff's naive LCS bails out to delete-all/insert-all even more cheaply than the engine's capped Myers reaches the same answer; all three pipelines emit the identical diff here. So the honest reading depends on the baseline, and both are shown: a win against a real competitor, a loss against a reference that degrades faster. The engine's absolute cost on this case is tracked as issue #12.
- Once the DOM dominates. In a real Chromium tab, rendering every row of a large diff into the DOM costs far more than computing it, so end to end the pipelines finish within 1.00x to 1.02x of each other on the browser-measured cases (identical fast path aside) — and against jsdiff, where compute is already near parity, that tie is immediate. The browser section below shows this in full; since M10 the site renders a virtualized window instead of every row, which is what keeps large diffs responsive.
Every case
Every case in the run, ranked by the jsdiff bar. The cluster around 1x is the parity band; the two bars that run off to the right are the bounded-worst-case wins. The vs refdiff column is the in-repo comparison for context — where it and vs jsdiff disagree, both are shown rather than the flattering one alone.
| case | what it is | input, chars | runs | refdiff | jsdiff | wasm (call + assembly) | vs jsdiff | vs refdiff |
|---|---|---|---|---|---|---|---|---|
| tiny-snippet | the "Load example" pair every visitor sees (343 + 360 chars) | 343 + 360 | 50 | 0.049 ms | 0.043 ms | 0.067 ms | 0.64x | 0.73x |
| small-10kb | 330 lines / ~10 KB, 12 edited lines in one zone | 10,350 + 10,389 | 50 | 0.236 ms | 0.158 ms | 0.149 ms | 1.06x | 1.58x |
| mid-40kb | 1,300 lines / ~40 KB, 22 edited lines in one zone | 40,982 + 41,070 | 50 | 3.30 ms | 0.500 ms | 0.451 ms | 1.11x | 7.32x |
| large-150kb-sparse | 5,000 lines / 150 KB, 44 edited lines (the committed marketing fixture) | 152,579 + 152,846 | 50 | 5.89 ms | 1.85 ms | 1.52 ms | 1.21x | 3.87x |
| large-150kb-identical | 150 KB, byte-identical sides: measures the identical-input fast path plus boundary floor, NOT engine speed | 152,579 + 152,579 | 50 | 0.688 ms | 1.30 ms | 1.12 ms | 1.16x | 0.62x |
| lines-10k | 10,000 lines / ~310 KB, 60 edited lines in one zone | 315,272 + 315,633 | 50 | 5.73 ms | 4.02 ms | 3.05 ms | 1.32x | 1.88x |
| large-1mb-sparse | 33,000 lines / ~1 MB, 72 edited lines in one zone | 1,038,890 + 1,039,396 | 25 | 10.26 ms | 13.09 ms | 12.94 ms | 1.01x | 0.79x |
| huge-5mb-sparse | 165,000 lines / ~5.4 MB, 89 edited lines in one zone (the M10 large-file target) | 5,263,685 + 5,264,324 | 7 | 80.58 ms | 72.53 ms | 62.97 ms | 1.15x | 1.28x |
| huge-13mb-sparse | 400,000 lines / ~13 MB, 97 edited lines in one zone | 12,900,754 + 12,901,554 | 5 | 200.51 ms | 247.93 ms | 173.46 ms | 1.43x | 1.16x |
| large-1mb-spread | 33,000 lines / ~1 MB, ~110 edits spread across the whole file: past the reference LCS bailout, so the reference degrades to a full rewrite while the engine stays minimal; output quality differs and added/removed counts are reported per side | 1,040,315 + 1,040,931 | 25 | 67.47 ms | 14.94 ms | 10.57 ms | 1.41x | 6.38x |
| complete-rewrite | 3,000 vs 3,000 lines with nothing in common: both engines take their documented degradation path (reference LCS bailout, engine depth cap) and emit the same del-all/ins-all diff | 94,757 + 94,365 | 25 | 6.81 ms | 1152.93 ms | 21.75 ms | 53.01x | 0.31x |
| adversarial-repeats | 700 lines per side drawn from a 12-line pool, independently shuffled: maximal ambiguity; both engines produce minimal diffs that may differ in shape but must agree on counts | 21,607 + 21,553 | 50 | 6.34 ms | 28.58 ms | 3.24 ms | 8.82x | 1.96x |
| minified-json | single-line minified JSON, ~150 KB per side, one value edited mid-string: exercises intra-line refinement with prefix/suffix trimming on one huge line | 145,124 + 145,145 | 50 | 8.50 ms | 8.98 ms | 3.61 ms | 2.49x | 2.36x |
Counts note: jsdiff (real Myers) agrees with the engine on added/removed line counts on every case, which the benchmark asserts before timing. refdiff does not: on large-1mb-spread it degrades past its LCS bailout (js +32701/-32701 vs wasm +110/-110 (documented divergence: reference degrades past its bailout)), so its ratio there compares different amounts of useful work and its vs refdiff number favors the engine. The large-150kb-identical bar measures the disclosed identical-input fast path plus the boundary floor, not engine speed. minified-json compares against jsdiff diffWords, not diffLines: its single line makes the line-level diff a non-comparison against the engine's intra-line refinement.
Where the time goes
Phases of the wasm pipeline on large-150kb-sparse: 5,000 lines / 150 KB, 44 edited lines (the committed marketing fixture). The result marshal is the cost of crossing the wasm boundary, the part M9 rewrote: with the sparse contract it is 0.034 ms here, measured as the difference between the compute call and a probe call that does the same work but returns only a checksum.
| phase | median |
|---|---|
| refdiff total (compute incl. views), for context | 5.89 ms |
| jsdiff total (incl. views), for context | 1.85 ms |
| wasm engine compute (probe) | 1.01 ms |
| wasm compute call (engine + boundary) | 1.05 ms |
| result marshal across the boundary (derived) | 0.034 ms |
| full view assembly in JS (pre-M10 page path) | 0.478 ms |
| row model build (M10 page path) | 0.177 ms |
| one 60-row render window (M10) | 0.003 ms |
| wasm total (call + full assembly) | 1.52 ms |
| wasm total (M10 page path) | 1.23 ms |
In a real browser tab
The Node numbers above compare compute. A browser tab also has to render the result, and painting every row of a large diff into the DOM dwarfs either compute path. Measured in headless Chromium on the real page (node v24.18.0 · AMD Ryzen 9 5900X 12-Core Processor · commit c00db09, scripts/bench-browser.results.txt): on the 150 KB fixture the shared split-view DOM render is 239.00 ms, so despite a 4.25x compute win the end-to-end ratio including render is 1.02x. Stated plainly: once the full DOM render dominates, the engines tie. That is why M10 made the site render a virtualized window instead of every row.
The browser run predates the jsdiff baseline and still compares against refdiff, so its compute ratios are the refdiff numbers, not the jsdiff ones; the point it exists to make — that a full DOM render swamps any compute difference — is independent of which baseline the compute is measured against. Against jsdiff, where compute is already near parity on these sparse cases, the end-to-end tie is only more immediate. Adding jsdiff to the browser harness is follow-up work; the Node run above is where the engine-versus-jsdiff comparison lives.
| case | input, chars | runs | JS reference | wasm (call + assembly) | DOM render (shared) | ratio w/o render | ratio incl. render |
|---|---|---|---|---|---|---|---|
| tiny-snippet | 343 + 360 | 30 (render: 30) | 0.100 ms | 0.100 ms | 1.20 ms | 1.00x | 1.00x |
| large-150kb-sparse | 152,579 + 152,846 | 30 (render: 10) | 6.80 ms | 1.60 ms | 239.00 ms | 4.25x | 1.02x |
| large-150kb-identical | 152,579 + 152,579 | 30 (render: 10) | 0.600 ms | 1.10 ms | 205.30 ms | 0.55x | 1.00x |
| lines-10k | 315,272 + 315,633 | 20 (render: 5) | 5.95 ms | 2.40 ms | 429.80 ms | 2.48x | 1.01x |
| large-1mb-sparse | 1,038,890 + 1,039,396 | 10 (render: 2) | 8.90 ms | 7.00 ms | 1754.20 ms | 1.27x | 1.00x |
The large-150kb-identical row measures the disclosed identical-input fast path plus the boundary floor, not engine speed.
Engine size
The engine ships as a 45.7 KB WebAssembly binary (measured from the built web/pkg/diffwtf_wasm_bg.wasm) plus generated JS glue. The site uses static HTML, CSS, and vanilla-JS modules; diff computation requires no server round trip.
Methodology
Every displayed benchmark value is parsed out of the committed artifacts by scripts/gen-bench-page.mjs, which regenerates this page and the home page chart; CI fails if the pages and artifacts diverge. CI never runs the benchmark itself — it only checks the committed numbers against the committed pages — so no benchmark dependency is installed in CI.
Main run (scripts/bench-vs-js.results.txt): node v24.18.0 · AMD Ryzen 9 5900X 12-Core Processor · commit 9780e0e · jsdiff 5.2.2. Reported values are medians, 10 warmup runs per pipeline (huge cases override per bench-cases.mjs), word granularity, interleaved. Three pipelines run in the same Node process (V8, the engine Chrome uses), interleaved so none gets a systematic cache or GC advantage: the wasm engine module the site ships, jsdiff (npm diff, pinned; a dev-only dependency of the benchmark harness that is never bundled into the site), and the in-repo refdiff reference. jsdiff runs diffLines for line structure, except minified-json, whose single line makes diffLines a non-comparison, where it runs diffWords to match the engine's intra-line refinement. Inputs are committed fixtures or deterministic seeded generators (scripts/bench-cases.mjs), reproducible from any checkout.
Sanity checks run before timing: jsdiff and the engine must agree on added and removed line counts on every case (jsdiff is real Myers, so it agrees even where refdiff degrades past its LCS bailout on large-1mb-spread), and every pipeline's output must reconstruct both inputs. All three totals include materializing every row into a renderable view, so the comparison charges each pipeline for the same view work and none is credited for skipping it. On complete-rewrite the engine and jsdiff verifiably emit the identical diff; on the ambiguous adversarial-repeats they emit different but equally minimal diffs — both are checked, not assumed.
Fairness notes, disclosed: the engine additionally refines within changed lines (word-level highlights) on the line cases, which jsdiff diffLines does not, so on the sparse cases the engine reaches parity while doing more, not less. The identical-input fast path is a product shortcut and never backs an engine-speed claim. The Node runner is not a browser page; the browser run exists to check that the story holds there.
Reproduce it: npm install (to fetch the pinned jsdiff), ./scripts/build-wasm.sh, then node scripts/bench-vs-js.mjs (and node scripts/bench-browser.mjs for the Chromium run), from the repo. The committed results files are regenerated by piping stdout there and committing the diff, so the artifact history is reviewable like any other code.