Playwright tests flaky in CI are almost always caused by one of six things: async state that is not ready, selector drift, test-order pollution, environment differences between local and CI, third-party network calls, or animations blocking interaction. This guide works through each root cause with the concrete fix, so you spend less time re-running pipelines.
Why are my Playwright tests flaky in CI?
Playwright tests flaky in CI but green locally nearly always trace to one of four environment gaps: slower CPU means async state is not ready when the assertion fires; headless Linux rendering differs from headed macOS; the browser binary version drifted between your machine and CI; or parallel workers share state they were never designed to share.
The numbers confirm this is not an edge case. According to a 2026 flaky-test benchmark by TestDino, Google reports that 16% of their test suite exhibits flaky behavior, and 2% of developer coding time is lost to flaky-test investigation. Atlassian documented 150,000 developer hours wasted per year across their engineering organization. Microsoft puts the annual cost at $1.14 million for their org. The Bitrise Mobile Insights report (analyzing 10 million builds over three years) found the share of teams experiencing flakiness grew from 10% in 2022 to 26% in 2025.
Root cause research from Luo et al. (FSE 2014, cited in the TestDino benchmark) attributes:
- 45% of flaky UI tests to async wait issues
- 20% to concurrency problems
- 12% to test-order dependency
Those three categories cover three-quarters of all flakes. The sections below address each.
How do you fix async wait problems in Playwright?
Replace every waitForTimeout() call with an assertion or event that waits on actual application state. Playwright's auto-wait handles actionability before a click() or fill(), but it cannot know when your app's internal data store has finished updating after the action.
The Playwright best-practices documentation draws a sharp line between two assertion styles:
// Fragile: reads the DOM once, fails if the text is not there yet
expect(await page.getByText('Order confirmed').isVisible()).toBe(true);
// Correct: retries for up to 5 seconds (the default expect timeout)
await expect(page.getByText('Order confirmed')).toBeVisible();
The web-first assertion on the second line retries on a short polling interval until the condition is true or the timeout expires. That is what makes it resilient to the 50-100ms async gap between a button click and the UI update it triggers.
For cases where you need to wait for a network response (a common POST-submit pattern), use waitForResponse instead of a fixed delay:
// Fragile: hopes 2 seconds is enough
await page.click('button[type=submit]');
await page.waitForTimeout(2000);
// Correct: waits for the actual response from your API
const [response] = await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200),
page.click('button[type=submit]'),
]);
await expect(page.getByText('Order confirmed')).toBeVisible();
Note that waitForResponse must be set up before the action that triggers the request, which is why the Promise.all pattern is necessary. Setting it up after the click introduces a race condition on fast connections.
For polling application state that is not directly tied to a network response, expect.poll is the right tool:
await expect.poll(async () => {
const status = await page.locator('[data-testid="job-status"]').textContent();
return status;
}, { timeout: 30_000 }).toBe('Complete');
Why do my Playwright selectors break in CI?
Selector drift is the other half of the "maintaining two apps: your product and your selectors" problem. CSS class names and XPath expressions are tied to implementation, not behavior. When a component refactor renames a class, every test using that class breaks with no functional change to the product.
The Playwright documentation recommends role-based locators because they target what the user sees:
// Breaks when the class name changes
page.locator('button.buttonIcon.episode-actions-later');
// Resilient: targets the button by its accessible name
page.getByRole('button', { name: 'Save changes' });
// Also resilient: targets a stable test attribute
page.getByTestId('submit-order');
For getByTestId to work, you add data-testid attributes to your HTML components. This is a one-time cost that decouples your test suite from your styling decisions. Teams that have made this switch report a significant drop in selector-related breakage after the next refactor cycle.
When you need to target an element inside a list, chain and filter rather than using a positional index (positional indexes break when the order changes):
// Fragile: breaks if another item is added above this one
page.locator('li:nth-child(2)');
// Resilient: targets the item by its text content
page.getByRole('listitem').filter({ hasText: 'Product 2' }).getByRole('button', { name: 'Remove' });
How do you fix test isolation problems in Playwright?
Tests that share browser state, database rows, or a single storageState file between parallel workers will produce intermittent failures that look timing-related but are actually data collisions.
Each test should own its own auth state and its own test data. The pattern for auth is to create one storageState file per role at setup time, then reuse it across tests without modifying it:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
One common mistake: using a single playwright/.auth/user.json for both an admin role and a regular user role. When those two setup steps run in parallel, they overwrite each other. Use separate paths per role: playwright/.auth/admin.json and playwright/.auth/member.json.
For database-level isolation, use unique identifiers generated per test run so parallel workers never collide on the same row:
test('creates a new project', async ({ page }) => {
const projectName = `Test Project ${Date.now()}`;
// ...create a project with this name, assertions scoped to it
});
How do you eliminate network-related flakiness in Playwright?
Third-party API calls, slow staging endpoints, and non-deterministic server responses all introduce flakiness you cannot control from the test. The fix is to intercept and control the response at the test layer.
Playwright's page.route() lets you intercept any request and return a canned response:
test('shows error when payment fails', async ({ page }) => {
await page.route('**/api/payments', async route => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({ error: 'Card declined' }),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByText('Card declined')).toBeVisible();
});
This converts a test that depended on a real payment processor into a deterministic, fast, offline-capable test. The same pattern applies to third-party auth redirects, analytics endpoints, and feature-flag services.
For tests that need to verify the request was sent with the right payload (not just mock the response), use waitForRequest:
const requestPromise = page.waitForRequest(req =>
req.url().includes('/api/analytics') && req.method() === 'POST'
);
await page.getByRole('button', { name: 'Confirm order' }).click();
const request = await requestPromise;
expect(JSON.parse(request.postData()!)).toMatchObject({ event: 'order_confirmed' });
Why do Playwright tests fail due to animations and CSS transitions?
Animations that are still running when an assertion fires cause flakiness because the element is technically visible but in an intermediate visual state, or because pointer-events: none is set during a transition.
The fastest fix for a test environment is to disable animations globally via a CSS override injected before each test:
// playwright.config.ts
use: {
// ...
}
// In your global setup or a beforeEach hook:
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
});
For modals and overlays, wait for the overlay to be fully visible (not just attached to the DOM) before interacting with content inside it:
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Now safe to interact with elements inside the modal
await modal.getByRole('button', { name: 'Confirm' }).click();
How do you fix environment drift between local and CI Playwright runs?
The most reliable fix is to pin CI to the official Playwright Docker image, which locks the browser binary, system fonts, and rendering engine to the same version your tests were written against.
# .github/workflows/e2e.yml
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.49.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test
env:
HOME: /root
Three other environment settings commonly cause local/CI divergence:
Timezone: CI runners default to UTC. If your app renders dates or runs time-sensitive logic, set TZ=UTC locally too, or pass timezone in the test config:
use: {
timezoneId: 'UTC',
locale: 'en-US',
},
Viewport: CI headless Chrome defaults to an 800x600 viewport unless you set it. If your tests assume a desktop layout, set this explicitly:
use: {
viewport: { width: 1280, height: 720 },
},
Workers: Running with --workers=1 locally but --workers=4 (or the default) in CI means tests that look isolated are actually running in a different order and concurrently. Either set workers: 1 in CI while you fix the isolation issues, or accept that you need proper test isolation (see the section above) before raising the worker count.
How do you diagnose a Playwright test that fails in CI with no useful error?
Enable trace and video capture on failures so you have a visual record of what the page looked like when the assertion fired.
// playwright.config.ts
use: {
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
Then in your CI configuration, upload the artifacts:
- name: Upload Playwright traces
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
retention-days: 7
After downloading the artifact, open the trace locally:
npx playwright show-trace trace.zip
The Playwright trace viewer shows a DOM snapshot at every action step, the network requests that fired, console logs, and the timeline of each assertion's retry attempts. It is the single most effective debugging tool for CI-only failures because it replays exactly what the remote browser saw.
For verbose API-level logging without a trace file, run with:
DEBUG=pw:api npx playwright test
This logs every Playwright API call and its arguments to stdout, which can surface selector resolution failures that are otherwise swallowed.
When automation is not enough: what human QA catches that selectors miss
A green Playwright suite is evidence that the flows you thought to automate work on the machines you thought to test. That is genuinely valuable. But it is a subset of "the product works for a real user."
The ai-test-false-positives problem is that automated suites, including well-maintained Playwright suites, structurally over-test the happy path. They test what the author knew to test. A human tester who is not the author, who does not share your assumptions, finds the checkout edge case after the cart discount is applied, the session that does not survive a tab close, the copy that makes no sense to someone seeing it for the first time.
This is what the phrase "green != correct" captures. Your Playwright CI can go green every day for a month and still ship something broken. The QA without a QA team pillar covers the full framework for thinking about this. The PR-triggered testing playbook shows how to wire automated and human checks together in a single pipeline gate. If you are looking at the cost of adding human verification, our pricing page shows what hybrid QA (AI agents plus human testers) looks like at $300/mo and up.
Fixing your flaky tests is the right first step. Once the suite is reliable, you can trust the green badge to mean what it says, and then the real question becomes whether the coverage is deep enough to catch what a first user will find.
If you want five free test runs with a human-verified pass on your most critical flows, that is exactly what the Simz waitlist gets you.