You write a visual test with Playwright, run it on your laptop, and it passes. You push, and CI fails on the same page with a diff full of red: text a pixel wider, a font that isn’t yours, an image slightly softer. Nothing in the code changed. The screenshots did.
This is the most common problem with toHaveScreenshot(), and Playwright’s own docs explain why: browser rendering can vary “based on the host OS, version, settings, hardware” and more. Their advice is just as short: “run tests in the same environment where the baseline screenshots were generated.” This post is about doing that in practice, and about the page-level causes that make screenshots flaky even in the same environment.
If you’re new to visual testing in Playwright, start with our Playwright visual regression testing guide.
Why the same test gives different screenshots
Baselines are named per browser and platform
Look at the file Playwright creates on the first run. The docs’ example is example-test-1-chromium-darwin.png: the snapshot name, then the project (browser) name, then the platform, which is the value of Node’s process.platform. A baseline made on a Mac ends in darwin. A Linux CI runner looks for one ending in linux, finds nothing, and fails the test as missing.
That naming is deliberate. Playwright doesn’t pretend a Mac screenshot and a Linux screenshot should match, so it keeps them apart. If you commit only the darwin files, CI has no baseline at all.
Fonts and text rendering differ between operating systems
Text is where most cross-machine differences show up. macOS, Linux and Windows ship different system fonts and render text differently, so if your CSS ever falls back to a system font, or a web font loads late, the same page lays out differently. A heading that fits on one line on a Mac can wrap on Linux and push everything below it down.
Browser builds and headless modes differ
Each Playwright version pins its own browser builds, so a different @playwright/test version can mean a different browser. Headless Chromium is a separate build too: Playwright’s docs say it ships “a regular Chromium build for headed operations and a separate chromium headless shell for headless mode.” Comparing a headed run on your laptop with a headless run in CI is comparing two browser builds.
Hardware and settings
Playwright’s snapshot docs also list settings, hardware, power source (battery or power adapter) and headless mode among the causes. Things like the GPU and display scaling can nudge anti-aliasing and image scaling by a pixel here and there. You can’t control those on every developer’s machine, which is the real argument for generating baselines somewhere you can control.
The page itself isn’t still
Some differences have nothing to do with the machine:
- Animations and transitions caught mid-frame.
- The text caret blinking in a focused input.
- Dates, times and countdowns that change between runs.
- Ads, live chat, carousels and random content that differ on every load.
- Lazy-loaded images that haven’t loaded when the screenshot is taken.
Playwright handles the first two by default, as the next section shows. The rest are up to you.
What toHaveScreenshot() already does for you
Before adding workarounds, it’s worth knowing the defaults:
animations: "disabled"is the default. CSS animations, transitions and Web Animations are stopped: finite ones are fast-forwarded to their end, and infinite ones are reset to their initial state. So if a test fails on an animation, check whether you’ve setanimations: "allow"somewhere.caret: "hide"is the default, so a blinking cursor doesn’t fail the test.scale: "css"is the default: one screenshot pixel per CSS pixel, so a high-DPI laptop doesn’t produce bigger images than CI.- It waits for a stable page. The assertion keeps taking screenshots until two in a row are identical, then compares the last one with the baseline.
threshold: 0.2is the default per-pixel colour tolerance, from 0 (strict) to 1 (lax).
So animations and the caret are rarely the cause. Fonts, environments and dynamic content usually are.
Fix 1: generate and compare baselines in the same Docker image
The most reliable fix is to never create baselines on a laptop. Create them, and compare against them, in Playwright’s official Docker image, both locally and in CI.
The image “includes the Playwright browsers and browser system dependencies”, but not the Playwright package itself, which comes from your project. Tags follow the Playwright version and Ubuntu release, for example v1.64.1-noble for Playwright 1.64.1 on Ubuntu 24.04.
The tag has to match the Playwright version in your project. If it doesn’t, Playwright can’t find its browsers. Check yours with:
npx playwright --version
Then update baselines from inside the image instead of on your machine:
docker run --rm --ipc=host --init \
-v "$(pwd)":/work -v /work/node_modules -w /work \
mcr.microsoft.com/playwright:v1.64.1-noble \
/bin/bash -c "npm ci && npx playwright test --update-snapshots"
A few notes on that command:
- Replace
v1.64.1with your version. --ipc=hostis what Playwright’s docs recommend when using Chromium, and--initavoids problems with the container’s first process.-v /work/node_moduleskeeps the container’s Linuxnode_modulesseparate from the one on your Mac, sonpm ciinside the container doesn’t overwrite yours.npm cineeds apackage-lock.jsonin the project.- If your tests use a dev server running on your machine, the container can’t reach it at
localhost. Let Playwright start it withwebServerin the config, or usehost.docker.internal. - On a Linux host, files created in the container are owned by root, because the image runs as root by default. Docker Desktop on Mac and Windows handles this for you.
--update-snapshotswithout a value defaults to “changed”: it updates snapshots that don’t match and creates missing ones. Use--update-snapshots=allto rewrite every baseline, or=missingto add only new ones.
The baselines now end in -linux.png, and they’re what CI will compare against.
Fix 2: run CI in the same image
Use the same image tag in CI. In GitHub Actions, Playwright’s CI docs run the job in the container:
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
playwright:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- run: npx playwright test
Now the baseline and the comparison come from the same OS, the same browser build and the same fonts. When you upgrade Playwright, change the image tag in the same commit and regenerate the baselines in that image.
Fix 3: make the page hold still
Docker fixes the environment. These fix the page.
Freeze time. page.clock can take over Date, timers and requestAnimationFrame with clock.install(). For screenshots, setFixedTime() is usually enough: Date.now() and new Date() always return the time you set, and timers keep running.
await page.clock.setFixedTime(new Date('2026-01-01T09:00:00Z'));
await page.goto('/');
Mask what changes on every load. The mask option paints a box over the locators you pass, pink by default, so the content of ads, chat widgets and live counters can’t fail the test. A change in their size or position still can, because it moves everything around them:
await expect(page).toHaveScreenshot('home.png', {
fullPage: true,
mask: [page.locator('.ad-slot'), page.getByTestId('live-chat')],
});
Hide things with a stylesheet. stylePath applies a CSS file during the screenshot. It’s useful for elements that are easier to hide than to mask, and you can set it for every test in the config:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
stylePath: './tests/screenshot.css',
},
},
});
/* tests/screenshot.css */
.cookie-banner,
.carousel-controls,
iframe[src*='youtube'] {
visibility: hidden !important;
}
Make fonts predictable. Playwright already waits for the page’s fonts to finish loading (document.fonts.ready) before each screenshot, so slow web fonts are rarely the problem. Font differences usually come from a system-font fallback, which differs between operating systems. Self-host the fonts your design uses and preload them, so every machine renders the same ones.
Wait for images. For full-page screenshots of pages with lazy-loaded images, scroll to the bottom first, or load those images eagerly in your test environment.
Fix 4: loosen thresholds last
It’s tempting to make a flaky test pass by raising the tolerance:
await expect(page).toHaveScreenshot('home.png', { maxDiffPixelRatio: 0.01 });
maxDiffPixels and maxDiffPixelRatio allow a number or a share of pixels to differ, and threshold loosens how different each pixel’s colour may be. They’re useful for a page with a little unavoidable noise. But a tolerance big enough to hide font and OS differences is also big enough to hide a real bug, like a button that lost its padding. Fix the environment first, and keep thresholds small.
A checklist when CI fails and local passes
- Is there a
-linuxbaseline, or only-darwin? If onlydarwin, generate the Linux ones in Docker. - Does the Docker image tag match
npx playwright --version? - Were the baselines made in the same image CI uses, headless, with the same projects?
- Does the diff show text? Check for system-font fallbacks, which differ between operating systems.
- Does it show dates, ads or carousels? Freeze time, mask or hide them.
- Is
animationsstill"disabled"? - Only then: does a small
maxDiffPixelRatiomake sense for this page?
When a hosted tool is simpler
Everything above is worth doing if your visual tests live in a Playwright suite your developers own. If what you need is to check a website’s pages, the environment problem can be someone else’s.
That’s how Diffy works. It takes screenshots with Playwright, in Chrome or WebKit, on Diffy’s own workers rather than developers’ laptops, so no baseline depends on whose machine made it. You give it a list of URLs, and settings for masking, custom CSS and JavaScript take the place of mask and stylePath. Sites that only run locally can use its local screenshot worker, which can run in Docker.
The free plan covers 500 screenshots a month. See Diffy vs Playwright for an honest comparison of the two approaches.
Frequently asked questions
Why do my Playwright screenshots fail in CI but pass locally?
Because the baseline and the new screenshot were taken in different environments. Playwright’s docs say rendering varies with the host OS, version, settings, hardware and headless mode, and they name baselines per platform, so a Mac baseline ends in -darwin.png and a Linux CI runner needs a -linux.png one. Generate baselines in the same Docker image that CI uses.
Which Playwright Docker image should I use?
mcr.microsoft.com/playwright with a tag that matches the Playwright version in your project, for example v1.64.1-noble for Playwright 1.64.1 on Ubuntu 24.04. If the versions don’t match, Playwright can’t find its browsers.
Does Playwright disable CSS animations in screenshots?
Yes, by default. toHaveScreenshot() uses animations: "disabled": finite animations and transitions are fast-forwarded to their end, and infinite ones are reset to their initial state. Set animations: "allow" only if you want them running.
How do I update Playwright screenshots for Linux from a Mac?
Run the update inside the Playwright Docker image: mount your project, run npm ci, then npx playwright test --update-snapshots. The new baselines are named for Linux and match what a Linux CI runner produces.
Should I just increase the threshold?
Only a little, and only after fixing the environment. A tolerance large enough to hide font differences between operating systems is large enough to hide real layout bugs too.
Related reading: Playwright visual regression testing · How to avoid false positives in visual testing · Screenshot testing
