← All posts
GUIDE

Flaky Tests in Playwright: 7 Causes and How to Fix Them

Flaky Playwright tests: a CI pipeline where the same test passes and fails, with seven causes and their fixes

Short answer: a flaky Playwright test passes and fails on the same code. The usual causes are fixed waits, brittle selectors, shared test data, leaking state, real network calls, animations and CI environment differences. Fix them with web-first assertions, role-based locators, isolated data, mocks and a pinned config. Use retries with traces to find flaky tests, never to hide them.

Key takeaways

  • Replace every page.waitForTimeout() with a web-first assertion such as toBeVisible().
  • Use getByRole, getByLabel or test IDs instead of CSS paths.
  • Give each test its own data, and mock services you do not control with page.route().
  • Pin viewport, locale and time zone, and run CI in the official Playwright Docker image.
  • Turn on retries in CI only, record a trace on the first retry, and treat every “flaky” label as a bug with an owner.
  • Measure your flaky rate every week. If you do not track it, it only goes up.

What is a flaky test?

It is 4:40 on a Friday. The release branch is green on your laptop, red in CI, and green again when someone clicks “re-run”. Nobody changed a line of code. That is a flaky test: a test that passes and fails on the same code.

In Playwright, almost all flakiness comes from three places: timing, shared state and unstable selectors. The good news is that nearly all of it can be fixed in the test, not in the app.

The real cost is not the re-run minutes. It is trust. Once a team learns that red builds are often noise, people stop reading them, and the one failure that was real goes to production with everything else.

Playwright already marks a test as flaky in its report when it fails first and passes on retry (Playwright docs: retries). Treat that label as a bug report, not a pass.

What causes flaky tests in Playwright, and how do you fix them?

1. Fixed waits instead of conditions

A hard wait is a guess about how long the app needs. On a fast laptop the guess is right; on a busy CI runner it is wrong. Playwright’s API reference says plainly to never wait for a timeout in production tests (page.waitForTimeout).

checkout.spec.ts
// Flaky: hopes 2 seconds is enough
await page.click('#save');
await page.waitForTimeout(2000);
expect(await page.textContent('.toast')).toBe('Saved');

// Stable: waits until the condition is true
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();

Web-first assertions such as toBeVisible() and toHaveText() keep retrying until they pass or time out (Playwright docs: assertions). A plain expect() on a value you already read checks once and moves on.

For conditions that are not on the page, such as a background job or an API status, poll instead of sleeping:

export.spec.ts
await expect.poll(async () => {
  const res = await request.get('/api/export/123');
  return (await res.json()).status;
}, { timeout: 30_000 }).toBe('done');

2. Brittle selectors

Selectors tied to CSS classes or DOM position break when a designer moves a div, and can match the wrong element while the page is still rendering. Use locators that follow what the user sees (Playwright docs: locators).

invoice.spec.ts
// Fragile
await page.locator('div.form > div:nth-child(3) button').click();

// Resilient
await page.getByRole('button', { name: 'Create invoice' }).click();
await page.getByLabel('Email').fill('[email protected]');
await expect(page.getByTestId('invoice-total')).toHaveText('$120.00');

A simple rule we use in reviews: if a locator would break when the CSS changes but the user would not notice anything, it is the wrong locator.

3. Tests that share data

Two tests editing the same user, cart or record will collide once they run in parallel. One test’s cleanup becomes another test’s failure, and the failure moves around depending on which worker gets there first.

The fix: every test creates its own data. Do it in a fixture with a unique ID, and clean up after the test.

fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend<{ account: Account }>({
  account: async ({ request }, use) => {
    const account = await createAccount(request, `qa-${Date.now()}-${Math.random()}`);
    await use(account);
    await deleteAccount(request, account.id);
  },
});

Creating data through the API rather than the UI is also faster, so the suite gets quicker as it gets more stable.

4. State that leaks between tests

Playwright gives each test a fresh browser context, but your backend does not reset itself. Feature flags, queues, caches and seeded rows can carry over from an earlier test.

The tell-tale sign: a test passes when you run its file alone and fails in the full run. Reset what the test depends on in a beforeEach hook, or create it inside the test. Use serial mode only for flows that truly depend on order, and keep those groups small.

5. Real network calls to services you do not control

Third-party APIs, payment sandboxes and analytics scripts are slow or rate-limited at random, and none of that is your bug. Mock what is not under test (Playwright docs: mock APIs).

pricing.spec.ts
await page.route('**/api/exchange-rates', route =>
  route.fulfill({ json: { USD: 1, EUR: 0.92 } })
);

Keep a small set of end-to-end tests that hit the real services, and run them on a schedule, separate from the pull-request suite.

6. Animations and timing-sensitive UI

Toasts, modals and transitions can sit on top of a button or change a screenshot between runs. Playwright’s actionability checks wait for elements to be stable before clicking, but visual tests still need help.

dashboard.spec.ts
await expect(page).toHaveScreenshot('dashboard.png', {
  animations: 'disabled',
  mask: [page.getByTestId('last-updated')],
});

Mask dynamic values such as timestamps, and freeze dates in the app under test where you can.

7. Environment differences

“Passes locally, fails in CI” is almost always about the machine: screen size, time zone, fonts, CPU speed or a shared staging server. Pin these in the config so every run gets the same settings.

playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    viewport: { width: 1280, height: 720 },
    timezoneId: 'America/New_York',
    locale: 'en-US',
  },
  workers: process.env.CI ? 4 : undefined,
});

Run CI in the official Playwright Docker image so browsers and fonts match between machines (Playwright docs: Docker).

Flaky test causes at a glance

CauseTypical symptomHow to confirm itFix
Fixed waitsFails only on slow runnersFails more with more workersWeb-first assertions, expect.poll
Brittle selectorsFails after UI changesTrace shows wrong or missing elementgetByRole, getByLabel, test IDs
Shared dataFails only in parallelPasses with --workers=1Unique data per test
Leaking stateFails depending on test orderPasses alone, fails in full runReset in beforeEach
Real network callsRandom timeoutsTrace shows slow third-party callpage.route mocks
AnimationsClick intercepted, screenshot diffTrace shows element coveredDisable animations, mask areas
EnvironmentPasses locally, fails in CIPasses in a local Docker runPinned config, Docker image

How do you debug a flaky Playwright test step by step?

  1. 1 Reproduce it.Run the test on its own many times with --repeat-each=50.
  2. 2 Raise the pressure.If it still passes, run it with more workers or alongside its spec file. Flakiness that only appears in parallel points to shared data or state.
  3. 3 Read the trace, not the error.Open the failed attempt with npx playwright show-trace. Step through the actions and compare the DOM snapshot with what the test expected.
  4. 4 Name the cause.Match what you see to one of the seven causes in the table above.
  5. 5 Fix and prove it.Apply the fix, then run --repeat-each=50 again. 50 out of 50 is the bar.
  6. 6 Re-run only what failed.Use npx playwright test --last-failed to check the rest of the failures from the last run quickly.
terminal
npx playwright test tests/checkout.spec.ts --repeat-each=50 --workers=4

Should you use retries for flaky Playwright tests?

Yes, but only to keep the pipeline moving while you fix the cause. Used on their own, retries hide the problem and make every run slower.

playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

With trace set to on-first-retry, Playwright records a full trace of the retried attempt: DOM snapshots, network calls and console logs (Playwright docs: trace viewer). You pay the recording cost only when something is already wrong.

How do you quarantine a flaky test without losing it?

When a fix will take more than a day, move the test out of the blocking suite, but keep it running so you can see when it is fixed. Tags make this simple:

checkout.spec.ts
test('applies discount code at checkout', { tag: '@flaky' }, async ({ page }) => {
  // ...
});
terminal
# Blocking pull-request job: skip quarantined tests
npx playwright test --grep-invert @flaky

# Non-blocking nightly job: run only quarantined tests
npx playwright test --grep @flaky

Give every quarantined test an owner and a date. A quarantine with no exit date is just a slower way of deleting the test.

How do you measure your flaky rate?

Pick one number and track it weekly: flaky rate = tests marked flaky in the period ÷ total tests run in the period × 100.

Playwright’s JSON or HTML report gives you the flaky count per run. Chart it in the same place you track build times. A rising line is an early warning that the suite is losing trust, long before people start ignoring red builds.

How do you keep Playwright tests stable in CI? A 10-point checklist

Use this list before you blame the test. For the full pipeline setup, see our Playwright CI/CD integration playbook.

  • No page.waitForTimeout() in any committed spec.
  • Assertions use web-first expect (toBeVisible, toHaveText, toHaveURL).
  • Locators use roles, labels or test IDs, not CSS paths.
  • Every test creates its own data and cleans it up.
  • Third-party calls are mocked with page.route().
  • Viewport, locale and time zone are pinned in the config.
  • CI runs in the official Playwright Docker image.
  • Retries are on in CI only, with a trace on the first retry.
  • Flaky tests are tagged, owned, dated, and counted weekly.
  • New or changed tests pass --repeat-each before merge.

When should you get outside help with flaky tests?

Most teams can fix a handful of flaky tests themselves. Outside help makes sense when:

  • The flaky list grows faster than the team can fix it.
  • Developers re-run failed builds by habit instead of reading them.
  • Nobody owns the test suite between releases.
  • A large release is coming and the regression suite cannot be trusted.

QAFactory’s QA engineers stabilize existing Playwright suites, then keep them stable as your product changes. See our Playwright automation services, managed QA and regression testing.

Get a second pair of eyes

Send us your current failure list. A 48-hour QA audit from $299 shows which tests are flaky and why, or book a call to walk through it with a QA engineer.

Frequently asked questions

Why do my Playwright tests pass locally but fail in CI?

CI runners are usually slower and configured differently. Fixed waits, unpinned viewports or time zones, and shared staging data are the usual causes. Pin the config and run CI in the Playwright Docker image.

Should I turn on retries in Playwright?

Yes, in CI only, and with a trace on the first retry. Retries keep the pipeline moving; the trace shows you what to fix. Never treat a test that passes on retry as healthy.

How do I find which tests are flaky?

Playwright labels tests that fail and then pass on retry as flaky in its report. Run a suspect test with --repeat-each=50 to confirm.

Is waitForTimeout ever OK?

Only while debugging. In committed tests, wait for a condition with a web-first assertion or expect.poll instead.

How do I stop a flaky test from blocking releases?

Tag it (for example @flaky), exclude the tag from the blocking job with --grep-invert, and run it in a separate non-blocking job until it is fixed. Give it an owner and a date.

Can flaky tests be caused by the app, not the test?

Yes. Race conditions in the app can make a test flaky. When the trace shows the app itself behaving differently between runs, file it as a product bug.

About this article: written by Lucia Martinez, Senior QA Engineer at QAFactory. Sources: Playwright documentation on best practices, locators, assertions, retries, trace viewer, mocking, test tags and Docker. Last updated Oct 29, 2026.

← Back to all posts Talk to a QA lead →