Speed Up 4K Screenshot Diffs
Most of the time in a โslow image diffโ is not the diff. It is PNG decoding, the browser, and writing diff images you never look at. Fix those three and the comparison itself drops to a couple of hundred milliseconds per 4K pair, running entirely on your own machine.
Where the time actually goes
Measured on an M1 Max, from the pixel benchmarks:
| Work | 4K pair |
|---|---|
| Native Rust diff, image IO included | 215-269ms |
| Pure JS diff, image IO excluded | 97-136ms |
| WebAssembly diff, image IO excluded | 33-68ms |
| pixelmatch, image IO excluded | 202-423ms |
| odiff, image IO included | 1157-1677ms |
Compare rows 1 and 3: the same algorithm takes 33ms on decoded pixels and 215ms when it has to decode two PNGs first. Decoding is the majority of the wall clock. Optimizing the comparison while re-decoding the same baseline on every test is the usual mistake.
1. Pick the right core
| Package | Runs on | Input | Use when |
|---|---|---|---|
@blazediff/core-native | Node, native binary | File paths or encoded bytes | CI. Fastest end-to-end, decode included |
@blazediff/core-wasm | Anywhere wasm runs | Decoded RGBA | You already have pixels in memory |
@blazediff/core | Node, browser | Decoded RGBA | No native dependency allowed |
If you are on pixelmatch today, @blazediff/core is a drop-in replacement with
an identical API, about 1.5x faster. core-native is the bigger jump but takes
file paths.
import { compare } from "@blazediff/core-native";
const result = await compare("baseline.png", "current.png", "diff.png", {
threshold: 0.1,
antialiasing: true,
});2. Stop writing diff images you do not read
This is the single biggest easy win in JS. Passing an output buffer forces the diff to paint every pixel, not just count them:
| Mode | Average improvement over pixelmatch |
|---|---|
| No output buffer | ~62% |
| With output buffer | ~28% |
So pass undefined when a green run does not need a picture:
const changed = diff(img1, img2, undefined, width, height);With the agent, same idea:
blazediff-agent check --no-diff-png --jsonGenerate the diff image only on failure, in a second pass.
3. Let identical pairs short-circuit
Most snapshots in a healthy suite do not change. @blazediff/core has
fastBufferCheck on by default, which detects byte-identical inputs before doing
any per-pixel work. On identical 4K pairs that is 2.5-3.8ms against pixelmatchโs
24-28ms - roughly 88% faster.
Do not disable it unless you are benchmarking.
4. Parallelize, but cap it
The agent captures in parallel, defaulting to CPU count capped at 8:
blazediff-agent check --concurrency 4 --jsonHigher is not always faster. Every parallel browser context costs memory, and a CI runner that starts swapping is far slower than one running four at a time. If your job is memory-constrained, see reducing snapshot memory.
In Jest and Vitest, the matcher runs comparisons in a worker thread by default
(runInWorker: true), keeping the diff off the test thread.
5. Do not re-encode at level 9
The CLI writes diff PNGs at compression level 0 by default, which is the right
call - a diff image is a temporary artifact, not something to optimize for size.
Raising -c trades real CPU for disk you do not care about.
Why it is fast
Two passes over the image. The cold pass compares blocks and skips regions where
nothing changed, using SIMD - NEON on ARM, SSE4.1 on x86, v128 in WebAssembly.
The hot pass does per-pixel work only inside blocks that actually differ. On a
screenshot where 2% of the page changed, 98% of the image is settled by wide
vector compares.
Scan strategy is chosen at runtime from a density probe, so a sparse diff and a dense one take different paths.
The 20-minute test suite
If a Node suite spends 20 minutes on high-resolution canvas output, walk through this order:
- Are you decoding the baseline once per test? Decode once, reuse the RGBA buffer. This is usually the whole problem.
- Are you writing a diff image every run? Drop the output buffer on the pass path.
- Still on pixelmatch? Swap in
@blazediff/core- same API, ~1.5x. - Have pixels already in memory?
@blazediff/core-wasmruns a 4K pair in 33-68ms. - Working from files?
@blazediff/core-nativehandles decode in Rust. - Is the browser the bottleneck? Time capture separately from diff before optimizing further. It usually is.
Everything here runs locally. There is no upload step, so 4K screenshots of a private app never leave the machine, and speed does not depend on someone elseโs queue.