maxDiffPixels vs maxDiffPixelRatio in Playwright
This page is part of the tolerance thresholds section of the visual regression guide, which covers turning a measured difference into a verdict.
The specific question: Playwright offers maxDiffPixels and maxDiffPixelRatio, and they are not interchangeable. Choosing the wrong one for a component makes the gate either constantly noisy or quietly blind.
Problem statement
Both express “how much difference is acceptable”, and they express it against different denominators. maxDiffPixels is an absolute count. maxDiffPixelRatio is that count divided by the image’s total pixels.
The consequence is that a single ratio applied across a suite behaves completely differently on a badge and on a full page — and the direction of the error is the dangerous one: it is tightest where components are smallest and most permissive where the image is largest, which is the opposite of what most suites need.
Root cause
A defect’s size is a property of the defect, not of the image containing it. A two-pixel icon shift displaces roughly the same number of pixels whether it happens in a badge or on a page.
A ratio therefore treats the same defect as significant in a small image and negligible in a large one. That is occasionally what you want — a large page genuinely has more places for trivial variance to accumulate — and it is emphatically not what you want when the large image contains a small, important component.
Minimal reproduction
// one ratio for the whole suite
export default defineConfig({
expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.002 } },
});
Badge 412 px differ = 21.5% -> fails, correctly
Full page 412 px differ = 0.0089% -> passes, and the icon is still broken
Step-by-step fix
1. Understand the three knobs separately
await expect(el).toHaveScreenshot('x.png', {
threshold: 0.2, // per PIXEL: how different two pixels must be to count
maxDiffPixels: 200, // per IMAGE: absolute count of counted pixels allowed
maxDiffPixelRatio: 0.002, // per IMAGE: that count as a fraction of total pixels
});
threshold decides what counts as a differing pixel; the other two decide how many counted pixels are acceptable. Setting both image-level options applies whichever is stricter.
2. Pick the denominator that matches the component
For a component library where most captures are similar in size, an absolute budget is simpler to reason about: 200 pixels is 200 pixels regardless of the component. For a suite spanning primitives and full pages, a ratio prevents a page-level shot failing on trivial accumulated variance.
3. Set it per tier, not globally
// test/thresholds.ts
export const THRESHOLDS = {
primitive: { threshold: 0.15, maxDiffPixels: 120 },
composite: { threshold: 0.2, maxDiffPixels: 400 },
page: { threshold: 0.2, maxDiffPixelRatio: 0.001 },
} as const;
What this does: names the policy, so a reviewer can see which tier a component belongs to and why its budget differs.
4. Use threshold for anti-aliasing, not the image budget
// wrong: absorbs edge noise by allowing 2000 differing pixels anywhere
{ maxDiffPixels: 2000 }
// right: stop counting near-identical pixels, keep the image budget tight
{ threshold: 0.25, maxDiffPixels: 200 }
What this does: raising threshold filters out the near-identical pixels that anti-aliasing produces before the count happens, which is targeted. Raising the image budget instead permits two thousand genuinely different pixels anywhere in the image.
5. Verify against a deliberate defect
# shift an icon by 2px, run, and confirm every tier still fails
npx playwright test --grep @visual
What this does: proves the budget still catches something real. A budget that has only ever been tuned downward from noise has never been checked in the direction that matters.
Verification
The two numbers to record for each tier are the measured noise floor and the diff produced by a deliberate one- or two-pixel shift. A budget sitting between them is correct; one above the shift is blind, and one below the noise is unusable.
Edge cases and caveats
-
Both options set. Playwright applies whichever fails first, so setting both gives you a floor and a ceiling — an absolute cap that protects small images and a ratio that protects large ones. For a mixed suite this is often the best answer.
-
thresholdis not a percentage. It is a per-pixel colour distance from 0 to 1, and confusing it with an image-level budget produces a setting far looser than intended. -
Cropped captures. Changing what a capture includes changes the total pixel count, so a ratio budget silently changes meaning when someone adjusts the locator. An absolute budget does not.
Recording why a budget is what it is
A threshold with no recorded justification is a number the next person will either trust blindly or change arbitrarily, and both are worse than the alternative. Writing down two facts alongside each budget makes it maintainable.
The measured noise floor. The highest diff observed across several runs of an unchanged commit, in that environment. This is the lower bound: a budget below it fails on nothing but noise.
The smallest defect the tier must catch. Usually a one- or two-pixel shift, expressed as the diff that shift actually produced when someone introduced it deliberately. This is the upper bound: a budget above it is blind to the class of defect the tier exists to catch.
export const THRESHOLDS = {
// noise floor 0.03% | 2px icon shift measures 0.87% | budget sits between
primitive: { threshold: 0.15, maxDiffPixels: 120 },
} as const;
Two things follow from having the bounds written down. When someone proposes raising a budget because a check is noisy, the question becomes whether the noise floor has moved — which is measurable — rather than a matter of opinion. And when the container image, the font stack or the browser version changes, it is obvious that both numbers need re-measuring, because both were properties of an environment that just moved.
The re-measurement is cheap: a handful of repeat runs and one deliberate defect. Doing it once a quarter, or after any change to the capture environment, is what keeps a threshold from silently ceasing to mean anything.
FAQ
Which should be the default for a component library?
maxDiffPixels, with threshold doing the anti-aliasing work. Component captures cluster in size, so an absolute count is stable and easy to reason about, and it does not silently change meaning when someone adjusts what the capture includes. Add maxDiffPixelRatio for the page-level captures specifically, where an absolute count would be unreasonably tight.
Can I convert one to the other?
Arithmetically yes — multiply the ratio by the image’s pixel count — and the result only holds for that image size. That is the whole difficulty: a converted value is correct for the component you converted it from and wrong for every component of a different size, which is why the choice is about the shape of your suite rather than about the numbers.
What about threshold alone, with no image budget?
That permits an unlimited number of differing pixels as long as each is different enough to count, which means an entirely re-rendered component can pass. threshold filters what counts; it does not bound how much. At least one image-level budget is required for the assertion to mean anything.
Does the same reasoning apply to Chromatic’s diffThreshold?
Partly. Chromatic’s diffThreshold behaves like Playwright’s per-pixel threshold — it controls how different two pixels must be before they count — rather than like an image-level budget. The image-level decision is made for you by the service, which is why Chromatic tuning is mostly about anti-aliasing sensitivity and masking, and much less about denominators.
Related
- Tolerance Thresholds — the parent section on setting and maintaining diff budgets
- Setting Failure Thresholds for Visual Diffs in CI — wiring the chosen budget into the gate
- Pixel Diff Algorithms — what the comparator counts before a budget is applied
- Dynamic Content Masking — removing the noise a budget would otherwise have to absorb