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.

The same 400 pixels, five different fractions Five rows. Four hundred differing pixels is 20.8 percent of an eighty by twenty-four badge, 8.3 percent of a button, 0.33 percent of a card, 0.07 percent of a table and 0.009 percent of a full page. Small components are better served by an absolute pixel budget, large ones by a ratio, and mid-sized ones by either. Component Size 400 differing px is So the budget is Badge 80 x 24 20.8% of it maxDiffPixels Button 120 x 40 8.3% maxDiffPixels Card 400 x 300 0.33% either Table 900 x 600 0.07% maxDiffPixelRatio Full page 1440 x 3200 0.009% maxDiffPixelRatio

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

Which budget suits your suite A decision node asking how much the components vary in size. A suite of similarly sized components works with a single absolute pixel budget. A suite spanning primitives and full pages needs a ratio, which scales with the image. A suite of mostly small components is better served by an absolute count, because a ratio is too coarse at that size. A suite of mostly full-page captures needs a ratio, because an absolute count would be far too tight. How much do your components vary in size? maxDiffPixels one absolute number works all about the same maxDiffPixelRatio scales with the image primitives and pages maxDiffPixels a ratio is too coarse mostly small maxDiffPixelRatio an absolute count is too tight mostly full-page

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 same defect, caught or missed by size A comparison of one two-pixel icon shift. On an eighty by twenty-four badge, 412 differing pixels is 21.5 percent, and both an absolute budget of two hundred pixels and a ratio of 0.002 catch it. On a full page of 1440 by 3200, the same 412 pixels is 0.0089 percent: the absolute budget still catches it, and the ratio budget passes, missing the defect entirely. the same 2px icon shift, both budgets Badge (80x24) 412 px differ = 21.5% maxDiffPixels: 200 -> FAIL (caught) maxDiffPixelRatio: 0.002 -> FAIL (caught) Full page (1440x3200) 412 px differ = 0.0089% maxDiffPixels: 200 -> FAIL (caught) maxDiffPixelRatio: 0.002 -> PASS (missed)

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.

  • threshold is 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.