On this page
You just watched the same test pass three times in a row on your machine, then opened the Actions tab and saw Timeout of 30000ms exceeded. with a waiting for locator(...) line under it. You rerun the job. Sometimes it passes. Nothing in the diff touches that test.
Playwright tests that pass locally and fail in CI are almost always hitting an environment difference, not a Playwright bug. The usual suspects, in order: CI is slower so races surface, auto-wait didn't cover the gap, headless CI uses a different browser binary and a 1280x720 viewport, parallel workers collide on shared state, and env vars or secrets differ. Pull the trace from CI first, then close each gap in config.
This post ranks the causes by how often they turn out to be the answer, then gives a diagnosis test and a copy-paste playwright.config.ts that makes CI and local behave the same. Everything below is verified against Playwright v1.61, the current release as of mid-2026.
CI is slower, so your races finally lose
Your laptop is probably an 8-core-plus machine with the app, the database, and the browser all warm. A GitHub-hosted ubuntu-latest runner gives you 4 vCPUs and 16 GB of RAM on public repos, and 2 vCPUs and 8 GB on private ones. Everything takes longer: server startup, API responses, rendering, hydration.
That extra latency doesn't create bugs in your tests. It exposes the ones already there. A click that fires 50 ms before a React re-render lands fine locally because the render finished 200 ms ago. On a 2-core runner the render is still in flight, and the test times out.
The defaults matter here. Playwright's test timeout is 30,000 ms and the auto-retrying assertion timeout is 5,000 ms. A expect(locator).toHaveText(...) that resolves in 800 ms locally can blow through 5 seconds on a loaded runner. Before reaching for test.slow() or a bigger timeout, though, check whether the wait is targeting the right thing, which is the next cause.
Auto-wait covers less than you think
Playwright's auto-waiting runs five actionability checks: Visible, Stable, Receives Events, Enabled, Editable. But coverage is uneven:
locator.click()waits for all five.locator.fill()only checks Visible, Enabled, and Editable.locator.focus()andlocator.press()perform no actionability checks at all.force: truedisables the non-essential checks entirely. If you added it to make a test pass locally, you told Playwright to click through whatever race exists, and CI is where that bill comes due.
Two subtler gaps. "Stable" means the element held the same bounding box for two consecutive animation frames, so a slow CSS transition on a slow runner can still shift the element after the check passes. And "Visible" only requires a non-empty bounding box without visibility:hidden; an element at opacity:0 counts as visible.
The biggest gap of all: auto-wait knows nothing about your data. It will happily click a button the moment it's actionable, even if the API call that populates the page hasn't returned. Asserting on content before the app has loaded it is exactly the shape of microsoft/playwright#34075: passes on the developer's macOS machine, fails on GitHub Actions with the assertion reporting no value at all while waiting for locator("h1") runs out the clock. The fix is web-first assertions (expect(locator).toHaveText(...) and friends), which retry for the full expect timeout, instead of one-shot checks or manual sleeps.
Headless CI runs a different browser at a different size
Two silent differences between --headed on your desk and headless in CI.
First, the viewport. Playwright defaults to 1280x720 regardless of your screen. If you debug with --headed in a maximized window, you're testing a layout CI never sees. A nav item that's visible at 1920 wide can be behind a hamburger menu at 1280, and the click misses.
Second, the binary. By default Playwright uses a separate Chromium headless shell build for headless runs and full Chromium for headed runs. Two different binaries can render differently, fonts and GPU paths included. Since v1.49 you can opt into the "new headless" mode, which runs the real Chrome binary in both modes, via channel: 'chromium':
playwright.config.ts (excerpt)
use: {
...devices['Desktop Chrome'],
channel: 'chromium', // same real-Chrome binary, headless and headed
},If a failure only reproduces headless, this is the first knob to turn. Pinning the viewport explicitly (even to the 1280x720 default) also documents the assumption so nobody debugs at the wrong size again.
Parallel workers colliding on shared state
Playwright runs test files in separate OS worker processes that cannot share state or global variables. That isolation is real at the process level, but it does nothing for the state your tests share underneath: the same database, the same test user, the same API rate limit.
Locally you might run one spec at a time while debugging. CI runs the whole suite, and two workers both logging in as [email protected] and mutating the same account will fail in ways that look random. The canonical fix is per-worker resources keyed on the worker's index, which Playwright exposes as testInfo.workerIndex (unique across the run) and testInfo.parallelIndex (0 through workers minus 1), also available as process.env.TEST_WORKER_INDEX and process.env.TEST_PARALLEL_INDEX:
fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { dbName: string }>({
dbName: [
async ({}, use, workerInfo) => {
// one database per worker, no cross-worker collisions
await use(`app_test_${workerInfo.workerIndex}`);
},
{ scope: 'worker' },
],
});The Playwright CI guide suggests workers: process.env.CI ? 1 : undefined for stability and reproducibility. That's a reasonable default, not a law; parallelism on CI is fine once your tests are actually isolated. But if going to one worker makes the failures disappear, you've confirmed shared state is the problem, and you should fix the isolation rather than paying the serial-runtime tax forever.
Env vars, secrets, and the webServer gap
Locally your app reads .env, your dev server is already running, and you're probably logged in from yesterday. In CI, none of that exists unless you wired it up. A missing API key often doesn't crash the app; it renders a logged-out or empty state, and the test fails on a locator that isn't there, which looks identical to a timing failure in the error output.
Playwright's webServer option is the standard way to close this gap. It starts your app before the suite, waits until the URL responds (2xx, 3xx, or 400 to 403 counts as ready, with a 60,000 ms startup timeout by default), and reuseExistingServer: !process.env.CI means locally it reuses your running dev server while CI always gets a fresh one from a known command. Note port is deprecated in favor of url. If your app needs secrets to boot, pass them through the job's env: block and remember the webServer process inherits process.env.
Browser version drift and container limits
Each Playwright version pins specific browser builds. v1.61 ships Chromium 149, Firefox 151, and WebKit 26.5. If you upgrade @playwright/test without rerunning npx playwright install, or your Docker image tag doesn't match your package version, you get either missing-executable errors or, worse, tests running against a different browser version than your machine. The docs are blunt about the Docker case: always pin the image to the exact version, e.g. mcr.microsoft.com/playwright:v1.61.0-noble, or Playwright can't locate the browsers at all.
If you run in containers, two flags prevent a class of pass-locally-crash-in-CI failures: --ipc=host is recommended for Chromium, which can otherwise run out of memory and crash inside the default IPC namespace, and --init prevents zombie processes. Random browser crashes mid-suite on CI with no test-level cause is the signature of the memory case.
The CI-parity config
Every option here is current in v1.61 and documented above:
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: 'http://localhost:3000',
viewport: { width: 1280, height: 720 }, // the default, pinned explicitly
trace: 'on-first-retry',
screenshot: 'only-on-failure',
channel: 'chromium', // same real-Chrome binary headless and headed
},
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});forbidOnly fails the CI run if someone committed a test.only, which silently skips the rest of the suite otherwise. trace: 'on-first-retry' is the recommended CI setting: traces record only when a test actually retries, so the cost is near zero on green runs ('on' is explicitly not recommended for performance).
How to diagnose a CI-only failure
Work this in order; each step is cheap and rules out the steps after it.
- Get a trace before anything else. Set
trace: 'on-first-retry', let CI fail again, download theplaywright-reportartifact, and open the trace withnpx playwright show-trace trace.zipor by dragging it into trace.playwright.dev. You get DOM snapshots, network, and console at every step. Most CI mysteries end here, because you can see the page state at the moment of failure instead of guessing from an error string. - Reproduce locally under CI conditions. Match the config: headless, 1280x720, same worker count. Then stress it with the official repro recipe:
npx playwright test failing.spec.ts:20 --repeat-each=100 --workers=10 -x. High repetition plus worker contention simulates a slow runner;-xstops on the first failure so you can inspect it. - Check the environment causes. Diff CI env vars against local, confirm the Docker or Playwright versions match, and look for browser crashes in the job log (add
DEBUG=pw:browserto see browser-level output). - Last resort: run the suite inside the CI image locally.
mcr.microsoft.com/playwright:v1.61.0-noblegives you the same OS, fonts, and browser builds CI uses. If it fails there and nowhere else, you're down to hardware speed and resource limits.
One honesty mechanism worth keeping: retries. retries: 2 on CI stops a flaky test from blocking merges, and Playwright marks such tests as flaky rather than passed, retrying in a fresh worker process. But retries are a tourniquet. If flaky results pile up, run a periodic job with --fail-on-flaky-tests so the debt stays visible instead of compounding quietly.
Where Smoketest fits
Everything above makes your tests match CI. It cannot make CI match production, where real DNS, real third-party scripts, and real deploy config live, and where some of your "flaky test" reruns were actually the app briefly broken. Smoketest runs test-level checks (login, checkout, the paths that pay your bills) against your real environments on a schedule, so failures that survive all this CI-hardening get attributed to the app instead of the suite.
FAQ
Why do my Playwright tests pass locally but fail in GitHub Actions?
Almost always an environment gap: GitHub runners have 2 to 4 vCPUs so timing races surface, CI runs headless at the 1280x720 default viewport with a different Chromium build, parallel workers collide on shared test data, and env vars or secrets present locally are missing in the job.
How do I debug a Playwright test that only fails in CI?
Set trace: 'on-first-retry' in your config, let CI fail, download the report artifact, and open the trace at trace.playwright.dev to see DOM snapshots and network at the failing step. Then reproduce locally with npx playwright test spec.ts:20 --repeat-each=100 --workers=10 -x under CI-matched settings.
Should I just increase timeouts to fix CI failures?
Sometimes, but diagnose first. If the trace shows the app legitimately needs more time on a 2-core runner, raising the test timeout or using test.slow() is fine. If the test is asserting before data loads or using force: true, a bigger timeout only makes the same failure slower.
Does Playwright use a different browser in headless mode?
By default, yes. Playwright ships a separate Chromium headless shell for headless runs and full Chromium for headed runs, and the two binaries can render differently. Setting channel: 'chromium' opts into the new headless mode introduced around v1.49, which uses the real Chrome binary in both modes.
Are retries a fix for flaky Playwright tests in CI?
No, they're containment. retries: 2 keeps a flaky test from blocking merges, and Playwright reports it as flaky rather than passed, so the signal survives. Fix the underlying race or shared state, and run a periodic job with --fail-on-flaky-tests so accumulating flakiness fails loudly instead of hiding.


