Managing Screenshot Baselines Across Platforms
A visual test that passes on your laptop and fails the moment it reaches CI is almost never a real regression. toHaveScreenshot() compares the pixels the browser actually painted, and the pixels a browser paints depend on the operating system underneath it: font rasterisation, the installed font set, scrollbar geometry, form-control chrome and device pixel ratio all change between macOS, Linux and Windows. Playwright's default snapshot path does not encode the operating system, so a baseline written on a developer machine and a baseline written by a Linux runner land in the same file and fight over it. This page shows how to give every platform its own baseline, how to nominate a single blessed platform so only one of them gates the pipeline, and how to tell a platform artefact apart from a genuine UI change.
Root cause: the default snapshot path has no platform axis
Playwright resolves every screenshot to a file using snapshotPathTemplate, which defaults to {snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}. That template separates baselines by test file and by project name, but it carries no token for the host operating system, so chromium on darwin and chromium on linux resolve to exactly the same path. The rendering underneath differs anyway: macOS rasterises text through Core Text with its own hinting and stem darkening, Linux uses FreeType driven by fontconfig, and a Linux container usually lacks Helvetica, Segoe UI and every commercial font your design system asks for, so it silently falls back. Subpixel differences in glyph edges are enough to blow past the default comparison tolerance.
The second half of the problem is workflow rather than pixels. Once two machines write to one path, --update-snapshots becomes a race: an engineer regenerates locally to unblock a branch, CI fails on the same test with the opposite diff, and the file oscillates in version control until someone gives up and deletes the visual suite. This page sits under Visual Regression Testing, within Debugging & Test Observability.
Minimal reproducible example
The test below is ordinary and correct. It fails across platforms purely because of where its baseline is stored and what painted it.
import { test, expect } from '@playwright/test';
test('dashboard renders consistently', async ({ page }) => {
await page.goto('/dashboard');
// Wait for a stable signal before painting — a screenshot taken mid-render
// produces a diff that looks like a platform problem but is really a race.
await expect(page.getByRole('heading', { name: 'Revenue' })).toBeVisible();
// Default path: tests/dashboard.spec.ts-snapshots/dashboard-chromium.png
// The same path is used on darwin, linux and win32 — that is the bug.
await expect(page.getByRole('main')).toHaveScreenshot('dashboard.png');
});
Run it once on macOS, commit the generated PNG, then run it on a Linux runner and you get one of two errors. If the file is missing entirely, Playwright reports Error: A snapshot doesn't exist at /work/tests/dashboard.spec.ts-snapshots/dashboard-chromium.png, writing actual. and fails the run so a missing baseline can never silently pass. If the file exists but was painted elsewhere, you get Error: Screenshot comparison failed: 12483 pixels (ratio 0.03 of all image pixels) are different. with expected, actual and diff attachments in the HTML report.
Step-by-step fix
- Put the platform in the snapshot path. Set
snapshotPathTemplateinplaywright.config.tsso{platform}and{projectName}both appear.{platform}resolves toprocess.platform—darwin,linuxorwin32— which gives each operating system its own directory tree instead of a shared filename. Every other token stays available, so you can keep test-file scoping while adding the missing axis. - Nominate one blessed platform. Pick the platform your CI actually runs — nearly always
linuxinside the official image — and treat its baselines as the only ones that gate merges. Keeping darwin baselines is optional and useful for local iteration, but they should never fail a pipeline, because nobody will maintain three sets of images by hand. - Generate blessed baselines inside the same container CI uses. Run the update through
mcr.microsoft.com/playwright:v1.55.0-noblelocally so the fonts, FreeType version and browser build match the runner byte for byte. The container discipline is the same one described in Dockerizing Playwright for Headless CI. - Freeze the rendering surface. Pin
viewportanddeviceScaleFactorin the project, and setscale: 'css',animations: 'disabled'andcaret: 'hide'inexpect.toHaveScreenshotso a retina laptop and a 1x runner produce the same pixel grid and no blinking cursor or half-finished transition lands in the frame. - Ship the fonts with the app under test. Self-host every family as WOFF2 and declare
font-familywith a concrete fallback rather than a system stack. A container that lacks a font substitutes a different one, and font substitution is the single largest source of cross-platform diffs. - Set tolerance per project rather than globally. Use
maxDiffPixelRatiofor pages whose content reflows andmaxDiffPixelsfor tight component shots; the tuning trade-offs are covered in Tuning Screenshot Comparison Thresholds. Never raisethresholdglobally to silence one platform. - Regenerate deliberately and review the images. Run
npx playwright test --update-snapshots=changedso only the baselines that actually differ are rewritten, then read the changed PNGs in the pull request. A regeneration that touches forty files when you changed one button is telling you the environment moved, not the UI.
The configuration below implements steps 1, 4 and 6 together. {testFilePath} is relative to testDir, so the resulting tree is tests/__screenshots__/linux/chromium/visual/home.spec.ts/dashboard.png.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// {platform} is process.platform: darwin | linux | win32.
// {projectName} keeps chromium, firefox and webkit baselines apart.
snapshotPathTemplate:
'{testDir}/__screenshots__/{platform}/{projectName}/{testFilePath}/{arg}{ext}',
expect: {
toHaveScreenshot: {
// Ratio, not absolute pixels — survives minor reflow on long pages.
maxDiffPixelRatio: 0.01,
animations: 'disabled', // freeze CSS animations and transitions
caret: 'hide', // blinking text caret is a per-run coin flip
scale: 'css', // normalise 1x runners against 2x laptops
},
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 }, // pin, never inherit
deviceScaleFactor: 1,
},
},
],
});
Step 3 then becomes a single npm script rather than tribal knowledge. Mount the repository into the official image, install nothing extra, and run the update with the container's own browsers: docker run --rm -v "$PWD":/work -w /work mcr.microsoft.com/playwright:v1.55.0-noble npx playwright test --update-snapshots=changed. Because the image pins both the browser build and the font set, the images it writes are the images CI will compare against, and the diff you commit is reviewable instead of mysterious. Keep the image tag in lockstep with the @playwright/test version in package.json; a mismatch between the two is the most common reason a suite that was green yesterday reports thousands of differing pixels today after a routine dependency bump.
Troubleshooting variants
Diffs appear only in text, never in layout
Glyph edges differing while boxes stay put is the signature of font substitution. Compare the fonts the two environments resolve by evaluating document.fonts.check() for each family, or dump getComputedStyle(document.body).fontFamily on both machines. The fix is to self-host the family and reference it directly rather than through a system stack, then rebuild the blessed baselines inside the container. If a font is genuinely unavailable on one platform, exclude the affected region with the masking approach in Masking Dynamic Regions in Snapshots rather than raising tolerance for the whole page.
The comparison fails with a size mismatch instead of a pixel diff
Error: Snapshot comparison failed: Expected an image 1280px by 720px, received 1512px by 982px. means the viewport itself changed, so no tolerance setting can help. Either a project inherited a device descriptor with a different viewport, or the runner has a visible scrollbar that the recording machine did not — Linux and Windows reserve layout width for classic scrollbars while macOS overlays them. Pin viewport explicitly in every project, prefer element-scoped screenshots over fullPage: true for pages whose height varies with data, and check the device emulation notes in Emulating Devices, Locales and Timezones.
Baselines pass locally but fail on one browser engine only
Each engine paints form controls, focus rings and default fonts differently, so a shared baseline across chromium, firefox and webkit cannot work. {projectName} in the template already separates them; confirm the file actually lives under the project directory rather than a stale flat path left over from an older configuration. Delete orphaned baselines whenever you rename a project, since Playwright never prunes them and a stale image will keep passing forever. Engine-specific rendering differences are surveyed in Running Chromium vs Firefox vs WebKit in Playwright.
Verification
Prove the setup three ways. First, delete the entire baseline directory for the blessed platform and run the suite twice: the first run must fail with A snapshot doesn't exist, and the second, after a container-based --update-snapshots, must pass without a byte changing. That round trip confirms generation is reproducible rather than accidental. Second, run the identical command on a second machine of the blessed platform and confirm git status is clean — any file that reappears as modified marks a screenshot whose content is still non-deterministic and needs masking or a stability wait. Third, inspect a deliberate failure in the HTML report and in the Playwright Trace Viewer, where the expected, actual and diff images are attached to the failing step so you can confirm the comparison used the file you think it did. Wire the update job into the pipeline alongside the sharding setup in Running Playwright Tests in GitHub Actions with Sharding, and remember that shards must share one baseline tree, not generate their own. If a screenshot still alternates between pass and fail on the same platform, treat it as a timing defect and work through Detecting and Fixing Flaky Playwright Tests before blaming the operating system.
Frequently Asked Questions
Should I commit baselines for every platform my team uses?
Only if someone owns them. One blessed platform generated in the CI container is the maintainable default, because a baseline nobody regenerates is a baseline that silently rots. Developers on other operating systems run the visual suite with --ignore-snapshots for fast feedback on functionality, then rely on the containerised job for the pixel verdict. Teams that genuinely ship desktop-specific rendering can add a second platform, but each extra platform multiplies the images a reviewer must look at.
Why does --update-snapshots rewrite files I did not touch?
Any regeneration run on a machine whose rendering differs from the blessed one rewrites everything it compares, because every image is technically different. That is why regeneration belongs inside the same container image CI uses. Use --update-snapshots=changed to limit writes to baselines that actually failed comparison, and treat a large unexplained churn as evidence that the browser version, the base image or the font set moved underneath you.
Where do baselines live and how do I stop the repository bloating?
They live wherever snapshotPathTemplate resolves, defaulting next to the test file in a -snapshots directory. Keep the images small by screenshotting a single component or region instead of fullPage: true, since a full-page capture of a data-heavy dashboard is both huge and unstable. For suites with hundreds of images, store them through Git LFS and delete orphans whenever a test or project is renamed, because Playwright never removes a baseline it no longer references.
Can I compare across platforms with a looser tolerance instead?
Raising threshold or maxDiffPixelRatio far enough to absorb font rasterisation differences also absorbs the regressions you are trying to catch — a shifted button or a wrong colour easily fits inside the same pixel budget as antialiasing noise. Separate the baselines by platform and keep tolerance tight; tolerance should cover rendering jitter within one environment, never differences between environments.