Skip to the article

Measurement Published Reading time 11 min

Measure the noise floor before you set a visual diff threshold

Two runs of the same build, on the same machine, with nothing changed between them: the page screenshots disagreed about nothing at all, and the canvas captures disagreed about up to 81 per cent of their pixels. One of those numbers tightened a threshold tenfold. The other disqualified a gate.

In short

Run the suite against itself: capture the same build twice on the same machine, compare run A with run B, and treat every difference that appears as pure instrument noise. A threshold below that number fails at random; a threshold far above it proves nothing. Here the procedure tightened the page threshold tenfold, from 0.001 to 0.0001, and disqualified the canvas gate outright — two identical runs disagreed by up to 81 per cent of a canvas's pixels, with SSIM falling to 0.018148 where the guessed gate had demanded 0.98. The second half of the procedure matters as much as the first: check that the instrument was pointed at the subject, because sixteen of the nineteen screenshots behind that measured zero are a flat magenta rectangle.

What to take away

  1. Capture the same build twice, compare it against itself, and treat every difference that appears as the floor below which no threshold means anything.
  2. Measuring made this repository's page threshold ten times stricter rather than looser — 0.001 to 0.0001 — because the instrument turned out to be byte-exact.
  3. A noise floor of zero is not proof of a good instrument: sixteen of the nineteen screenshots behind this one are 1,296,000 pixels of a single colour.
  4. When no threshold clears the floor, mark the gate blocked, keep recording the numbers, and name the specific change that would unblock it.
  5. Print the comparisons that did not happen next to the ones that passed, because a green count that absorbs its own blind spots is a manufactured pass.
  6. A noise floor is perishable, and the only reason anyone can tell is that it is committed, dated and small enough to read.

tests/golden/thresholds.json · 46f81ba, 2026-08-19

What is a diff threshold actually a measurement of?

A threshold answers one question — how much difference is meaningful — and that question cannot be answered until you know how much difference appears when nothing has changed. Most of the published advice skips that step entirely. Start under one per cent, it says, then tune it if the suite gets noisy.

Tuning against failures optimises for a green suite, which is the one thing a threshold must never be optimised for. This repository started where everyone starts, with numbers chosen by eye, and its config admitted as much in the file: the canvas numbers were marked PROVISIONAL, placeholders until a same-machine reference-versus-reference run established the true noise floor.

That run happened the same evening, in commit b5175ae, and it moved every number in the file. It moved them in the direction almost nobody expects — the measurement made the suite stricter, not looser, and it took one gate away completely.

Every threshold in the file, before the measurement and after it. Measuring loosened nothing.
Threshold Guessed, 46f81ba Measured, b5175ae
pixel.default.maxDiffRatio 0.001 0.0001
pixel.byViewport.mobile-390 0.002 0.0005, still provisional
pixel.byViewport.tablet-768 no row 0.0005, still provisional
ssim.page.meanMin 0.995 0.999
ssim.page.worstMin 0.9 0.95
ssim.canvas.meanMin 0.98 null, gate disqualified
ssim.canvas.worstMin 0.85 null, gate disqualified
observation listenerTotal no tolerance row maxDelta 24

tools/golden/noise-floor.mjs:75-93

How do you measure a noise floor?

The tool has one purpose and it is written in its first line: measure how much the instrument disagrees with itself. It serves a single build, captures the reference twice through the same capture script the real suite uses, and compares run A against run B. Whatever comes back is noise, because nothing changed between the two.

The comparison is deliberately thin — two numbers per image pair, no diff image, no interpretation. pixelmatch supplies a count of pixels that crossed a colour cutoff, and a first-party SSIM at an eight-pixel window with a stride of four supplies the perceptual view. Both the mean and the worst window are kept, because a shader regression is usually local and a mean of 0.99 can hide a broken corner.

Three populations come out and they are never merged: structural screenshots, raw canvas captures, and observation drift — every non-pixel field of the per-checkpoint digest that differed between two runs of the same page. Percentiles are reported as min, p50, p95 and max over each sorted set.

The entire comparison, from tools/golden/noise-floor.mjs. Two metrics per pair and nothing else.
function comparePair(a, b) {
  if (a.width !== b.width || a.height !== b.height) return { ok: false, reason: 'size-mismatch' }
  const total = a.width * a.height
  // No diff image is wanted here — only the count. pixelmatch accepts an
  // undefined output buffer for exactly this case.
  const changed = pixelmatch(a.data, b.data, undefined, a.width, a.height, {
    threshold: 0.1,
    includeAA: false,
  })
  const score = ssim(a, b, { windowSize: 8, stride: 4 })
  return {
    ok: true,
    diffRatio: changed / total,
    changedPixels: changed,
    totalPixels: total,
    ssimMean: score.mean,
    ssimWorst: score.worst,
  }
}

It costs exactly twice a single-viewport capture, and a viewport here is six to nine minutes of wall clock, so the measurement is a quarter of an hour rather than free. Its output is committed to the repository on the grounds that it is evidence, and small.

noise-floor.json · 2026-08-19 · n=19 and n=89

What did two identical runs disagree about?

Same machine, same browser, same URL, two runs of the Golden Master homepage at 1440 × 900, stamped 2026-08-19T19:33:49.368Z on Node v22.23.1. Nineteen structural screenshots and eighty-nine raw canvas captures came back from each side.

The structural population disagreed about nothing whatsoever: pixel diff ratio min 0, p50 0, p95 0, max 0, with eighteen of the nineteen pairs byte-identical and the nineteenth a marquee that is captured live on purpose, because continuous motion is the behaviour under test. The canvas population disagreed about almost everything: p95 0.025829475308641974, max 0.8112244897959183, an SSIM mean falling to 0.0181484156493548 and a worst window reaching -0.886465409531798.

That negative reading is a real value rather than a fault. SSIM runs from -1 to 1 as Wang, Bovik, Sheikh and Simoncelli defined it in 2004, and this repository's unit test pins the fact by asserting that an inverted image scores below zero. A metric that can legitimately go negative is telling you the two windows are structurally anti-correlated, which is what a canvas caught at two unrelated moments looks like.

  • 0 structural pixel diff ratio min, p50, p95 and max, over 19 screenshot pairs
  • 81% canvas pixels that differed the maximum over 89 raw canvas pairs
  • 0.018148 worst canvas SSIM mean the guessed gate had demanded 0.98
  • 447 vs 431 listenerTotal, one checkpoint the only non-pixel field that moved
The measured floor, read out of tests/golden/noise-floor.json. Two runs, nothing changed between them.

That last one is the reason to run this over more than screenshots. listenerTotal read 447 on one run and 431 on the other at the settled hero, a drift of 16, while rootFontSizePx, documentHeight and globalsLeaked held exactly across both runs and across all 21 checkpoints of a later matrix. The assertion was demoted from exact to tolerant with a maxDelta of 24 — above the observed 16 and below anything that would read as a listener leak — and the others stayed exact, because the measurement said they could be.

The same discipline — measure the condition before you trust the number — produced this site's published accessibility figure, where an uncontrolled condition inflated the count about 140 times.

thresholds.json ssim.canvas · enforced false

When no threshold clears the floor, block the gate

The guessed canvas gate demanded an SSIM mean of at least 0.98. The measured floor is 0.018148. There is no threshold between those two numbers, and no amount of tuning would have found one: a suite shipped at 0.98 would have failed on every run, been fixed by loosening, and ended up proving nothing about the highest-risk surface on the page.

So the gate was not relaxed, it was disqualified. enforced false, meanMin null, worstMin null, and a status string that names the condition rather than shrugging: BLOCKED, needs deterministic time control on the scene side. The comparator honours that in code and not only in prose — it computes the SSIM on every run, records the mean, the worst window and the coordinates of the worst window, and returns ok regardless.

The real cross-target run confirms the verdict from the other direction. Across the 306 canvas SSIM rows comparing the two builds, the mean has a median of 0.7284514638837428 and the worst window has a median of -0.20839572391449632. A gate whose median worst window is negative between two builds believed correct cannot separate a regression from noise.

PASS
The comparison happened and the checkpoint agreed. In the committed run, 120 of 132 checkpoints come back at ratio exactly 0.000000.
FAIL
The comparison happened and the difference exceeded what the measured floor allows. The single failure in that run is a stale ledger row, not a pixel.
BLOCKED
The comparison did not happen — the instrument could not hold the subject still, or no threshold clears its noise. Counted separately, never absorbed into the pass count.
informational
The number is computed and recorded on every run and never fails the build. It is the evidence that will justify turning the gate on later.
The four outcomes this comparator distinguishes. Only the first two are statements about the page.

BLOCKED is only defensible when it carries a named unblocking condition, and here that condition landed. The rebuild's own WebGL scene exposes a test hook that renders exactly one frame at an explicit time, and with it the same content became gateable byte for byte: a pinned frame is now asserted identical across two independent page loads. Parity against the legacy stays a human review against captured stills, by construction, because the legacy cannot be pinned to a frame.

Demanding determinism also found two defects that looking at the image would not have. A composite pass left a settled frame blended over an uncleared buffer, caught because frames pinned at 1.5 seconds and at 9 seconds came back byte-identical — a pinned time that changes nothing is a uniform that is not reaching a shader. That zero difference was the bug, and a comparator tuned only to detect too much change would have reported green.

Pinning a canvas to one frame is the same capability a canvas needs to answer a reduced-motion request honestly — what WebGL and Rive owe prefers-reduced-motion.

sha256 d8ca0518 · 5,851 B · one colour

A noise floor of zero can mean the lens cap is on

The structural row of that measurement reads 0 four times over, and the thresholds file quotes it as eighteen of nineteen checkpoints byte-identical across two runs. Both statements are exactly true. Decoding the committed screenshots shows what they are true about.

The reference homepage at initial-load is 1440 × 900, 5,851 bytes, and one colour: 1,296,000 pixels of #ff00ff. So is hero-settled, and hero-pointer-corner, and otot-section, and footer, and twelve more. Sixteen checkpoints, at sixteen different scroll positions from y 0 to y 17809, share a single sha256.

This is the documented mask policy working exactly as specified. The reference page carries two full-viewport canvases, and before each shot a fixed magenta overlay is pinned to each canvas's viewport rect at the top of the stacking order — never drawn into the canvas itself, because filling one through a 2D context destroys its contents and would corrupt a WebGL surface outright. Two full-viewport canvases produce a full-viewport overlay at every scroll position.

What the nineteen structural screenshots behind that zero actually contain, by decoding each PNG and counting distinct RGB triples.
Checkpoint Canvas policy Distinct colours
initial-load mask 1
hero-settled mask 1
fourteen more, same sha256 mask 1
marquee capture, live 797
menu-open hide 14,060
menu-closed-again hide 14,080

The finding is discoverable only because the instrument writes down what it refused to look at. Every screenshot record carries the rectangles it excluded, and the architecture doc names that field as one of the two things to read before trusting any pixel result. Nothing in the repository is false; the sample size behind one sentence is simply smaller than the sentence implies.

The two checkpoints that do carry page pixels carry them by accident of policy. menu-open and menu-closed-again hide their canvases instead of masking them, because the menu's canvas sits inside a container that is still transforming when the mask rect is computed, so a fixed overlay would drift off it. Both were byte-identical across the A and B runs, and both are still byte-identical in an independent full-matrix run five days later — which is real evidence, and it is why the tightened 0.0001 is right. It rests on two images, and the file says eighteen of nineteen.

Nor is it a one-off. Decoding every structural screenshot named in the two full-matrix manifests gives 108 of 126 on the Golden Master and 114 of 132 on the rebuild that are 100 per cent one colour, at all six viewports, on both targets. The cheapest check available here is a one-liner: decode a baseline and count its distinct RGB values before you trust a zero.

menu-open vs menu-closed-again · 3 of 6

The second way a green checkpoint can be about nothing

A noise floor tells you what your instrument can resolve. It cannot tell you whether the checkpoint was aimed at anything, and those two failures look identical from the summary line.

At three of the six viewports — breakpoint-991, tablet-768 and mobile-390 — the screenshot named menu-open and the screenshot named menu-closed-again have the same sha256, on both targets. The click that opens the menu is recorded successful, failedInteractions is 0 everywhere, and the pixel comparison passes at ratio 0 in both directions because both targets are equally blank. Parity between the two builds is genuinely preserved. The checkpoint whose stated purpose is to prove that the close path leaves no residue proves nothing at half the matrix.

The observation record contains the answer and it is split across two adjacent fields, one of which is far more inviting to read than the other.

interactions
A click on the hamburger, ok true, then a wait of 1200 ms, ok true. The digest reports failedInteractions 0 at every viewport on both targets.
navGroups.opacity
1 — which reads as the menu being open, and is the field anyone debugging this would check first.
navGroups.rect
x 0, y 0, width 0, height 0. The element has no box. At desktop-1440 the same node measures 128.794 by 60 at x 16.656, with a 1.2 scale transform.
pixel
Ratio 0 against the other target, comfortably below the measured floor. The comparison is statistically sound, correctly configured, and about nothing.
The mobile-390 record for the open menu, field by field. The field a reader checks first is the one that misleads.

The general rule has two halves and no threshold can merge them. Measure how much the instrument disagrees with itself, and separately assert that every checkpoint reached the state its name claims — a visible element, a non-zero box, a digest field that actually moved between the before and the after. The first half is arithmetic over images. The second is a different assertion entirely, and it is the one that stops a suite from grading its own blank paper.

tests/.golden-diff/report.json · 2026-08-24

Count the comparisons that did not happen

The committed cross-target run reads 132 compared, 132 passed. It also reads 162 canvas comparisons that never took place, and the comparator prints that number next to the pass count deliberately: a run that says 132 of 132 while silently holding a hundred unmade comparisons is the shape of a manufactured pass.

The largest category is a crop artefact, which is the page agreeing with itself while the screenshot cropper does not. An element 13.328125 CSS pixels wide came back cropped to 15 by 14 on one side and 14 by 15 on the other, because the element screenshot scrolls the element into view first and the sub-pixel origin moves under it. The fix is not a tolerance: the CSS box is gated and enforced, 468 of 468 box checks passing, while the PNG size is recorded as informational — the box is what the page decides, the crop is what the cropper decides, and only the first is a statement about the build.

The other two categories are cheaper to explain and far more expensive to run. 192 of 792 canvas capture attempts found no box to photograph at all. 66 attempts timed out at 30 seconds each, every one of them on a single canvas — the reference's page transition, which never held still for one of its 66 attempts. That is at least 1,980 seconds of a 2,620.6-second matrix run: three quarters of the wall clock spent waiting to photograph something that could not be photographed.

captured 534
no box to photograph 192
timed out at 30 s 66
792 canvas capture attempts across the reference full-matrix run of 2026-08-24.Bars are to scale against the 534 that succeeded.

The rebuild's own matrix, a slightly larger one at 132 checkpoints, finished in 4.82 minutes against the reference's 43.68. Nearly all of that difference is one canvas that cannot be pinned to a frame, which is a performance fact and an instrument fact at the same time: the thing your suite cannot photograph is also the thing it spends its afternoon on.

Wall clock is its own measurement problem, and the scroll layer on this site got the same treatment — Lenis against native scroll, measured rather than argued.

pixelmatch 7.2.0 · checked 2026-08-26

What do pixelmatch and Playwright thresholds actually set?

Half the confusion about thresholds is that the word names two different numbers. In pixelmatch — the library under this comparison, and the model Playwright's own screenshot assertion follows — threshold is a per-pixel colour-distance cutoff and not a per-image tolerance. In version 7.2.0, the one pinned here, threshold 0.1 becomes a maximum squared YIQ distance of 35215 × 0.1 × 0.1 = 352.15, against a maximum possible value of 35215. The function then returns a count of the pixels that crossed it.

The per-image allowance is a separate number, and it is the one a noise floor tells you. Playwright makes the split visible and leaves half of it empty: threshold defaults to 0.2, while maxDiffPixels and maxDiffPixelRatio have no default at all — so out of the box, a single sufficiently different pixel fails the assertion. Vitest ships no default allowance either and says so outright, recommending a ratio computed on the size of the screenshot rather than a fixed count.

One more thing the word hides: the metric underneath it is not stable across versions. pixelmatch on its main branch has replaced the YIQ distance with the OKLab HyAB metric, so threshold 0.1 is not one fixed quantity over time. And includeAA reads backwards from its name — false, which is the default and this repository's setting, means anti-aliasing detection is on and AA pixels are not counted as differences.

What each tool's defaults set, and where the second number is simply missing.
Tool Per-pixel cutoff Per-image allowance
pixelmatch 7.2.0, this repository threshold 0.1, squared YIQ maxDiffRatio 0.0001, measured
pixelmatch, main branch threshold, OKLab HyAB none, it returns a count
Playwright toHaveScreenshot threshold 0.2, YIQ unset by default
Vitest browser mode not exposed no default, a ratio recommended

None of these defaults knows anything about your page, which is the entire point of measuring your own. Both Playwright and Vitest also tell you to regenerate baselines in the environment you will compare them in, and every number quoted above is one machine, one Chromium build, headless, with no determinism flags passed at capture time — no font-render-hinting, no forced device scale. The 0.0001 allowance is described in the file as headroom for font rasterisation across machines, which is headroom for a case this repository has not yet measured.

noise-floor.json · one commit, never re-run

How to set a threshold without guessing

The procedure is short and most of it is bookkeeping. What makes it hold up is that the output is committed beside the numbers it justifies, so its age is visible in a git log rather than in somebody's memory.

  1. Serve one build, capture it twice Nothing may differ between the two runs except the moment they happened. Use the same capture tool the real suite uses rather than a simplified stand-in, or you have measured a different instrument.
  2. Diff A against B on everything you assert Per-pixel count, a perceptual metric with the worst window kept as well as the mean, and every non-pixel field your digest records. listenerTotal moved here by 16; documentHeight did not move at all.
  3. Set each threshold above its own p95 With headroom you choose deliberately, and with the measurement written next to the number as a comment. A threshold nobody can trace is a threshold anybody can loosen.
  4. Where nothing clears the floor, block the gate Record the numbers, never fail the build on them, and name the specific change that would unblock it. A blocked gate with a route back is honest; a relaxed one is decoration.
  5. Commit the measurement, dated and small Textual, in the repository, next to the thresholds it produced. That is what lets the next reader notice it is stale instead of trusting it.
  6. Check the instrument saw the subject Decode a baseline and count its distinct RGB values. One colour means you photographed the mask. Then read whatever your tooling records about the regions it excluded.
  7. Assert each checkpoint reached its own name A visible element, a non-zero box, a digest field that changed between the before and the after. No threshold implies any of this, and a green suite without it is grading blank paper.
The whole procedure, including the two steps that are not about pixels at all.

Two caveats this repository demonstrates on itself, both of them visible in its own config. Two of its six viewports, both at deviceScaleFactor 2, have never had a floor measured at all, and their thresholds carry the admission where the next reader will meet it: not yet measured, provisional. The architecture doc states the consequence without softening it — a pass or a failure at those two viewports proves nothing about the instrument, and no candidate should be signed off on them.

And the floor itself has exactly one commit. It was taken on 2026-08-19 and has never been re-run, while the scenario it measured has changed twice since and grown from 19 checkpoints to 21. A noise floor is perishable, it decays as soon as the page, the browser or the machine moves, and the only reason anyone can tell how stale this one is comes down to a small dated file sitting in version control next to the numbers it produced.

The same argument settled a typographic number on this site, where a ratio everyone repeats was replaced by a line-height floor computed from the outlines.

Instrument work like this is part of how the sites here are built, which is described in the four disciplines.

Questions

How many runs do I need for a usable noise floor?

Two runs were enough here, because the signal was unambiguous in both directions: the structural pass came back byte-identical and the canvas pass came back with an 81 per cent maximum. Two runs are enough to disqualify an instrument, and enough to justify tightening a threshold that already reads zero. They are not enough to characterise a distribution — the percentiles in the committed file are over 19 and 89 samples drawn from a single pair of runs, and nothing in the repository claims more. A floor that lands somewhere in the middle is the case where you need more runs, not fewer.

My noise floor came back at zero. Is that good?

Check what is in the image before you celebrate. The structural floor here is exactly zero, and sixteen of the nineteen screenshots behind it are 1440 × 900 of a single colour, because the capture tool masks canvases and the page carries two full-viewport ones. The threshold derived from that run is still correct, but it rests on two real screenshots rather than nineteen. Decode a baseline, count its distinct RGB values, and read whatever your tooling records about the regions it excluded.

What do I do when no threshold clears the noise floor?

Say so in the config and stop gating. This repository writes enforced false, meanMin null, worstMin null and a status of BLOCKED with the reason attached, and its comparator computes the SSIM anyway, records the mean, the worst window and its coordinates, and returns ok regardless. The reasoning is in the file: a gate that cannot distinguish a regression from noise is worse than no gate, because it teaches people to ignore red. Crucially, BLOCKED names the change that would unblock it — and when that change landed on the rebuild side, the same content became gateable byte for byte.

Can canvas or WebGL content ever be diffed per pixel?

Yes, exactly when the content has no unpinned dependence on time. A dot-field seeded from a hash of integer lattice coordinates, with no random source, no time seed and no date, was compared per pixel under reduced motion and came back with 365 painted pixels on each side, at the same 365 coordinates, mean alpha 53.742 on both and a per-pixel alpha difference of 0. The same field animated ranged over 630 to 1,841 painted pixels across five captures 400 ms apart on one target, from nothing but the moment of the shutter. Determinism is a property of the content's time dependence, not of the surface.

Does a measured noise floor mean I can stop stabilising the environment?

The opposite: the floor is valid only for the environment it was measured in, and it is the thing that tells you whether stabilising worked. This one was taken on a single machine, in one Chromium build, with no determinism flags passed at all, so it says nothing about a second machine. Two of the six viewports in this matrix have never had a floor measured and their thresholds say so in the config. Playwright and Vitest both give the same advice in their own docs: regenerate baselines in the environment you will compare them in.

Sources

Measured in this repository

  • tests/golden/noise-floor.json The measurement itself: 19 structural pairs, 89 canvas pairs, percentiles per population, and the one non-pixel field that drifted. One commit, 2026-08-19.
  • tests/golden/thresholds.json Every threshold with the reasoning it was derived from, the disqualified canvas gate, and the two viewports still marked provisional.
  • tools/golden/noise-floor.mjs The tool: one build captured twice, pixelmatch for the count and a first-party SSIM at window 8, stride 4, kept as mean and worst window.
  • tests/.golden-diff/report.json The committed cross-target run: 132 compared, 132 passed, 162 canvas comparisons not judged, 468 of 468 box checks enforced and passing.
  • docs/adr/ADR-013-golden-master-testing.md The alternatives considered and why each lost, including one screenshot per checkpoint with the tolerance tuned until it passes.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.