Your laptop has 12 cores, 32GB of RAM, and runs at 4 GHz. Your GitHub Actions runner has 2 cores, 7GB, and is currently sharing a hypervisor with 14 other jobs. That's why your test that ran in 800ms on your laptop takes 3.4 seconds in CI, and why the assertion that always landed on time at home now lands after Playwright already moved on.
This is the most-Googled Playwright problem of 2026. The Stack Overflow archive is full of it. "Tests pass locally fail in CI" appears in roughly 30% of SPA-test bug reports. The fixes are mostly the same seven patterns, repeated across hundreds of projects. Here they are, ranked by how often I find them in client code.
Table of Contents
- Race condition 1: Animation hasn't finished
- Race condition 2: API returns before assertion runs
- Race condition 3: Element exists but isn't interactable
- Race condition 4: Two API calls fire in arbitrary order
- Race condition 5: Service worker caching stale responses
- Race condition 6: Database state from a previous worker
- Race condition 7: Click before SPA hydration completes
- How to actually debug these in CI
- FAQs
Race Condition 1: Animation Hasn't Finished
Your test clicks a button. A modal animates in over 300ms. You assert the modal title. Locally the assertion runs after the animation. In CI it runs at frame 4 of 18, when the title element exists but is at opacity: 0 and transform: scale(0.9). toBeVisible() sometimes passes (technically it's in the DOM and not display:none) and sometimes fails depending on Playwright's internal visibility heuristic.
The wrong fix
await page.waitForTimeout(500); // Don't do this
The right fix
Disable animations globally for tests. Add this once in your playwright.config.ts:
use: {
// Disable CSS animations for stability
// Apply via init script — we'll define it next
}
Then create a global setup file that injects the CSS:
// tests/global-setup.ts
import { chromium } from '@playwright/test';
export default async function globalSetup() {
// Or apply via init script per context:
}
// In your fixtures:
const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`;
document.head.appendChild(style);
});
await use(page);
},
});
Animations now finish instantly. Tests are deterministic.
Race Condition 2: API Returns Before Assertion Runs (Or After)
You click "Save", the form fires POST /api/orders, the success toast appears. Your test asserts on the toast. Locally, the API takes 60ms — toast is up before your next line runs. In CI, the API takes 1.2 seconds — your expect fires before the toast renders, hits the timeout, fails.
The wrong fix
await page.click('button:has-text("Save")');
await page.waitForTimeout(2000); // Hope this is enough
await expect(page.getByRole('alert')).toContainText('Order created');
The right fix
Wait on the API response, not the wall clock:
await Promise.all([
page.waitForResponse(r => r.url().endsWith('/api/orders') && r.status() === 200),
page.getByRole('button', { name: 'Save' }).click(),
]);
await expect(page.getByRole('alert')).toContainText('Order created');
The Promise.all registers the listener before clicking, so the response handler attaches even if the request resolves immediately. This is the pattern that fixes maybe 40% of "works locally" failures.
Race Condition 3: Element Exists But Isn't Interactable
Your dropdown opens. You click an option. locator.click() sometimes fails with element is outside of the viewport or element intercepts pointer events. The element is in the DOM, but a transparent overlay is still fading out, or the dropdown is positioned off-screen until it animates in.
The fix
Don't use locator.click({ force: true }) to paper over this. It hides the actual bug. Instead, wait for the element to be actionable, which is what Playwright's auto-wait does by default — but only if you're using locators correctly.
// Wrong - might race with the dropdown overlay
await page.locator('text=Option 1').click();
// Right - locator scoped to the open dropdown panel
await page.getByRole('listbox')
.getByRole('option', { name: 'Option 1' })
.click();
Role-based locators understand ARIA semantics. They wait until the listbox is actually expanded and the option is reachable. See my CSS-to-role-locator migration post for the full pattern.
Race Condition 4: Two API Calls Fire in Arbitrary Order
Your page loads. It fires GET /api/user and GET /api/notifications in parallel. Your test asserts on the username and the notification count. The username assertion sometimes runs before /api/user resolves, sometimes after. Even worse, the notification count appearing first triggers a re-render that detaches your username locator handle.
The fix
Wait for both before asserting:
await Promise.all([
page.waitForResponse(r => r.url().endsWith('/api/user')),
page.waitForResponse(r => r.url().endsWith('/api/notifications')),
page.goto('/dashboard'),
]);
await expect(page.getByTestId('username')).toHaveText('Alice');
await expect(page.getByTestId('notif-count')).toHaveText('3');
Or, my preferred pattern when the page makes 5+ initial calls — wait for the network to be idle:
await page.goto('/dashboard', { waitUntil: 'networkidle' });
Note: networkidle is unreliable for apps with persistent connections (WebSocket, Server-Sent Events). For those, wait on a specific signal — a loading spinner disappearing works well.
Race Condition 5: Service Worker Caching Stale Responses
Your PWA caches API responses. Your test logs in as user A, asserts the dashboard. Then logs in as user B, asserts the dashboard — and sees user A's data, served from the service worker cache. Locally this rarely shows up because you're not running tests in quick succession. In CI, two tests in the same worker hit it.
The fix
Disable service workers in test mode:
// playwright.config.ts
use: {
serviceWorkers: 'block',
}
Available since Playwright 1.30. If you specifically need to test PWA offline behavior, scope a single project to keep service workers and run those tests serially.
Race Condition 6: Database State From a Previous Worker
Worker 1 creates a user with email e2e@test.com and asserts it appears in the user list. Worker 2 reads the user list, sees the user from worker 1, and panics because that user wasn't supposed to exist yet. Or worker 1 deletes the user, worker 2 expects it to exist.
The fix
Per-worker test data. Generate unique identifiers based on testInfo.parallelIndex:
test('user signup', async ({ page }, testInfo) => {
const email = `e2e-${testInfo.parallelIndex}-${Date.now()}@test.com`;
await page.goto('/signup');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
// ...
});
For larger projects, use a fixture that provisions a fresh database tenant per worker. See my multi-user auth state post for that pattern.
Race Condition 7: Click Before SPA Hydration Completes
Next.js, Remix, Nuxt — any framework with server-side rendering plus client-side hydration. The page renders the HTML almost immediately. Your test clicks a button. Nothing happens. Why? The button's React event handler hadn't attached yet because hydration was still in progress.
The fix
Wait for a stable signal that hydration has completed. Most frameworks expose one — Next.js fires the nextjs-router-mounted event, Remix sets document.body.dataset.hydrated. Or roll your own:
// In your app's root layout (Next.js example):
'use client';
import { useEffect } from 'react';
export function HydrationFlag() {
useEffect(() => {
document.body.setAttribute('data-hydrated', 'true');
}, []);
return null;
}
// In your test:
await page.goto('/dashboard');
await expect(page.locator('body[data-hydrated="true"]')).toBeVisible();
// Now safe to click
If you can't modify the app (testing a third-party page), wait on a known-interactive element instead — a button that you know is one of the first to attach:
await page.getByRole('button', { name: 'Sign in' }).waitFor();
// Hydration is done by the time the button's handler is responsive
How to Actually Debug These in CI
You can't sit at the CI machine. Two tools that have saved me hours:
1. Trace viewer with retain-on-failure
// playwright.config.ts
use: {
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
}
When the CI test fails, Playwright produces a trace.zip in the test results. Download it from your CI artifacts, then:
npx playwright show-trace trace.zip
You see every action, every network request, every DOM snapshot at the exact moment of failure. 90% of "why did this fail in CI" questions get answered in 30 seconds with the trace viewer.
2. Reproduce CI conditions locally
Slow your local browser to match CI:
npx playwright test --workers=1 \
--headed=false \
--use-cpu-throttling-rate=4
(cpu-throttling-rate isn't a built-in flag, but you can replicate it via Chrome DevTools Protocol in a fixture. There are existing patterns on GitHub for this.)
Or run inside a Docker container with constrained resources:
docker run --rm --cpus=2 --memory=4g \
-v $(pwd):/work -w /work \
mcr.microsoft.com/playwright:v1.59.0 \
npx playwright test
If a test fails in the constrained container but passes on your laptop, you've reproduced the CI race condition. Now you can fix it.
FAQs
How much should I increase Playwright's default timeout?
If you're tempted to bump timeout: 30000 to timeout: 60000, stop. You're hiding a real race condition. Fix the underlying wait, don't extend the timeout.
What about page.waitForLoadState('networkidle')?
Useful for traditional MPAs. Unreliable for SPAs with persistent connections. Prefer waiting on specific responses or DOM signals.
Does running with --workers=1 in CI fix everything?
It hides parallel-data conflicts (race condition 6). It does not fix animation, hydration, API timing, or service worker issues. And it makes your CI 4x slower.
Should I retry failed tests in CI?
retries: 1 in CI is reasonable as a safety net while you debug. retries: 3 means you've stopped trying to fix flakiness and you're just rolling dice. Senior teams use 0 retries and treat every flake as a bug.
What's the difference between waitForResponse and waitForRequest?
waitForRequest resolves when the request is sent. waitForResponse resolves when the response comes back. Use waitForResponse 99% of the time — you usually care that the data arrived, not that the request fired.
How do I handle React Suspense boundaries?
Wait for the suspended content to render via its content, not the loading skeleton. await expect(page.getByTestId('user-card')).toBeVisible() is the signal that Suspense resolved.
What if the API call I want to wait on is fired by a third-party script (Stripe, Auth0)?
Match the URL in waitForResponse with a regex. page.waitForResponse(r => /stripe.com.*tokens/.test(r.url())) works for any subdomain.
Can I disable animations only in test environments without changing the app?
Yes — the init script approach in race condition 1 injects CSS into the page from outside. The app code is untouched.
What about WebSocket-driven UI updates?
Wait for the WebSocket message via page.on('websocket', ...), then assert. There's no waitForMessage built in, but you can build one with a Promise.
Why does my test pass on Chromium but fail on WebKit in CI?
Different browsers tick layout at different rates. WebKit is generally slower to repaint. Apply the same animation-disable script and verify your locators don't depend on visual properties (colors, exact pixel positions) that vary across engines.
Wrap-Up
The 30% flake rate the industry quotes is real, and almost all of it traces back to one of these seven patterns. Fix them and your CI goes from "hope it passes" to "if it fails, there's a real bug." That's the threshold where automation actually saves time.
If your team has a flaky CI suite and you'd rather pay someone else to debug it for a week than do it yourself, that's literally my framework cleanup engagement. Or book a free call and I'll triage your top three flaky tests on the call.
Related reading:
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.