Threshold Tuning
When to Use
Use this guide when configuring how forgiving the diff is — too tight = flake; too loose = miss regressions.
Decision
| Scenario | Recommended threshold |
|---|---|
| Pixel-perfect, same OS, same browser | 0.05–0.10 |
| Default Playwright tests | 0.20 (the default) |
| Cross-OS comparisons (mac vs Linux CI) | 0.20–0.30 |
| Components with complex shadows / blur / video posters | 0.30 (combine with maxDiffPixels) |
| Different font hinting across OSes | Above 0.40 — switch tools or fix the env (pin Docker image) |
| Use | When |
|---|---|
maxDiffPixels |
Small components (a button is 200×40 = 8000 px; maxDiffPixels: 50 allows ~0.6% drift) and full-page shots — an absolute count means the same thing at every page height |
maxDiffPixelRatio |
Fixed-size captures only, where you want the budget to scale with the element |
Never use a ratio for full-page shots. The ratio is a fraction of image area, and a full-page image is as tall as the document, so the budget grows with page height and is loosest exactly where pages are tallest. 0.5% of 1440×900 is about 6,500 px — a sane gate. The same 0.5% of 1440×8000 is about 57,600 px, enough to hide a whole component. maxDiffPixels does not drift with page length. Mixing both is fine — Playwright takes the smaller of the two budgets, so the tighter constraint wins.
Pattern
Three knobs, three jobs:
| Option | Default | Job |
|---|---|---|
threshold (0–1) |
0.2 (Playwright) | Per-pixel YIQ color tolerance |
maxDiffPixels |
unset — means 0 | Absolute count of differing pixels allowed |
maxDiffPixelRatio (0–1) |
unset — means 0 | Fraction of total pixels allowed to differ |
Note: pixelmatch's library default for threshold is 0.1. Playwright passes through to pixelmatch but defaults to 0.2. unset is not a fallback default — Playwright resolves the budget as maxDiffPixels ?? (imageArea × maxDiffPixelRatio) ?? 0, so a config with neither key already runs a zero-pixel budget.
Start at zero, then measure your own floor. On a stabilized Drupal capture (frozen CSS, a lazy-content settle, document.fonts.ready, a double requestAnimationFrame), five runs against an unchanged site with every tolerance key removed produced 75 of 75 byte-identical captures — with the capture properly stabilized there was no nondeterminism left for a tolerance to absorb, and since unset means zero, no configuration was needed to get there. So the order is: stabilize the capture first, then run the suite several times against an unchanged site and set the budget from the residual you actually observe — which may well be nothing. A project that needs a large tolerance has an unstabilized capture, and the tolerance is hiding it rather than fixing it.
Global floor + per-test exceptions:
// playwright.config.ts — the floor
expect: {
toHaveScreenshot: {
threshold: 0.15,
animations: 'disabled',
caret: 'hide',
// No maxDiffPixels / maxDiffPixelRatio: unset means a zero-pixel budget.
// Add an absolute maxDiffPixels only once repeat runs on an unchanged
// site show a residual, and set it from that number.
},
}
Override per assertion only with a comment explaining why:
// Brand logo must match token RGB exactly — tighter threshold required.
await expect(logo).toHaveScreenshot({ threshold: 0.02 });
Common Mistakes
- Wrong: bumping
thresholdto silence a flake → Right: bandaid; investigate the root cause (env/font/animation) instead - Wrong: setting
threshold: 0.5+→ Right: effectively not testing - Wrong:
maxDiffPixelRatioon a full-page shot → Right: the budget is a fraction of image area, so it grows with page height and is loosest on the tallest pages; use an absolutemaxDiffPixelsinstead - Wrong: reaching for a tolerance before the capture is stable → Right: unset already means zero, and a stabilized capture can hold it; a large tolerance is hiding an unstabilized capture
- Wrong: per-test thresholds without a "why" comment → Right: six months later nobody knows why and the comment is the only justification for keeping the test