How to Fix Flaky Playwright Tests: Diagnose, Then Repair
To fix flaky Playwright tests, do three things in order. First, make flakiness visible with retries, so the report marks a test as flaky instead of hiding it. Second, capture a trace of the failing run. Third, find the root cause in the trace and fix that cause, instead of adding a longer wait. Most flakiness comes from five places: manual waits, brittle locators, shared state, order dependence, and unstable environments or data.
This guide walks through that process with the options and assertions exactly as the Playwright documentation defines them. You will also get a short policy for quarantining a test without forgetting about it, and a way to talk about all of this in an interview.
What a Flaky Test Actually Is
A flaky test passes and fails on the same code. Nothing in the product changed, yet the result did. That makes it worse than a plain failure, because people stop trusting the suite. When a red build might be noise, real bugs get ignored.
Playwright gives this idea a precise name. When retries are on, every test ends in one of three statuses:
- passed: the test passed on the first run.
- flaky: the test failed on the first run but passed when retried.
- failed: the test failed the first run and all retries.
The flaky status is useful. It tells you a test is unreliable even though the build went green. If you want the general definition, see the flaky test glossary entry.
Step 1: Detect Flakiness With Playwright Retries
By default, Playwright does not retry failing tests. Turn retries on in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Give failing tests 3 retry attempts
retries: 3,
});
You can also pass it on the command line:
npx playwright test --retries=3
Here is how Playwright runs them. Tests run in worker processes, and each worker has its own browser. If a test fails, the runner discards that worker and starts a new one. The new worker re-runs the failed test first, then carries on with the rest. A retry therefore starts from a clean browser, not from the broken state of the first attempt.
You can set retries for one file or group too:
test.describe.configure({ retries: 2 });
One warning about Playwright retries. They are a detector, not a cure. If you add retries and stop there, a flaky test still wastes CI time and still hides a real problem. Use the flaky status in the report as your to-do list.
If a test needs different behaviour on a retry, testInfo.retry tells you which attempt you are on. It works in tests, hooks and fixtures:
test('my test', async ({ page }, testInfo) => {
if (testInfo.retry)
await cleanSomeCachesOnTheServer();
// ...
});
Use this sparingly. If a test only passes after cleanup on a retry, the cleanup belongs in the test setup.
Step 2: Capture Evidence With the Trace Viewer
Guessing is slow. A trace shows you what happened. For Playwright trace viewer debugging in CI, record a trace on the first retry:
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: 1,
use: {
trace: 'on-first-retry',
},
});
The documentation lists the main trace values:
'on-first-retry': records a trace only when a test is retried the first time.'on-all-retries': records a trace for every retry.'retain-on-failure': records a trace for every test but deletes it when the test passes. It works without retries.'on': records a trace for every test. The docs do not recommend it because it is performance-heavy.'off': records no trace.
Each retried test then produces a trace.zip. Open it locally with:
npx playwright show-trace path/to/trace.zip
You can also drag the file into trace.playwright.dev. The docs say the trace loads in your browser and no data is sent externally.
Here is what to look at in the viewer:
- Actions: the locator and duration of each step. Hover to see how the DOM changed.
- Timeline: a film strip of screenshots. The slider filters the Actions, Console and Network views.
- Network: every request, with headers and bodies. Slow or failed calls show up here.
- Console: browser and test logs.
- Errors: the failure messages, marked on the timeline.
Compare the failing trace with a passing run of the same test. The difference between them is usually the cause. The Playwright best practices page also recommends traces for CI failures over only videos and screenshots.
Step 3: Find the Root Cause and Fix It
Once you have a trace, ask what the test assumed that was not true. The same few answers come up again and again. Treating each failure with a proper root cause analysis stops you from patching symptoms.
Cause 1: Manual waits instead of web-first assertions
A fixed sleep guesses how long the page needs. Sometimes the guess is too short and the test fails. Sometimes it is too long and the suite is slow. Replace the guess with an assertion that waits for the real condition.
Playwright's expect has async matchers that wait. The docs say Playwright re-fetches the element and checks it over and over until the condition is met or the timeout is reached:
await expect(page.getByTestId('status')).toHaveText('Submitted');
The common matchers include toBeVisible(), toHaveText() and toHaveCount(). Assertions time out after 5 seconds by default. You can pass a timeout to one assertion, or set it once through testConfig.expect.
Watch out for the opposite mistake. Non-retrying assertions do not wait, and the docs note they can make tests flaky. The best practices page also warns against wrapping a manual check like isVisible() in expect, because it returns immediately without waiting. Prefer this:
await expect(page.getByText('welcome')).toBeVisible();
For values that are not on the page, use expect.poll or expect.toPass. Polling an API until it returns the status you expect looks like this:
await expect.poll(async () => {
const response = await page.request.get('https://api.example.com');
return response.status();
}, {
message: 'make sure API eventually succeeds',
timeout: 10000,
}).toBe(200);
For a block of several steps, toPass retries the whole block until it passes. The docs note that by default it has a timeout of 0 and does not respect the custom expect timeout, so set one yourself when you use it.
Cause 2: Brittle locators
A locator tied to styling breaks when a designer changes a class name. A locator that matches several elements picks the wrong one on a slow day. The best practices page tells you to use built-in locators, which auto-wait and retry, and to prefer user-facing attributes:
page.getByRole('button', { name: 'submit' });
It lists this as the kind of selector to avoid:
page.locator('button.buttonIcon.episode-actions-later');
When a page has many similar items, narrow the result by chaining and filtering instead of using an index. Locators that describe what the user sees survive refactors.
Cause 3: Shared state between tests
Tests that share a user account, a cookie or a database row will step on each other, especially when they run in parallel. The docs say each test should run independently with its own storage, cookies and data. That keeps results reproducible and stops one failure from cascading.
Playwright already gives each test an isolated Page by default. Keep it that way. If many tests need a signed-in user, put sign-in in a beforeEach hook or reuse signed-in state through a setup project. Give each test its own data, such as a unique email address, rather than a record every test edits.
Cause 4: Test order dependence
If test B only passes because test A ran first, you have a hidden dependency. It fails the moment a worker restarts after a failure, because the new worker continues with the next test.
Playwright does have an explicit serial mode:
test.describe.configure({ mode: 'serial' });
In serial mode, if one test fails, the later tests are skipped, and with retries the whole group is retried together. Use it only when the steps truly are one workflow. For everything else, make each test create what it needs.
Cause 5: Environment and data problems
Some flakiness is not in the test at all. The best practices page gives two clear rules. Do not test external sites you do not control. Mock their responses with page.route and route.fulfill. And control your test data: test against a staging environment that does not change. For visual regression tests, keep the operating system and browser versions the same.
Look at the Network tab of your trace. A slow third-party call or a shared test database is a common culprit. For more on this, read about test environment management and test data management.
A Short Policy for Quarantining a Flaky Test
Sometimes you cannot fix a test today. Moving it out of the way is fine. Hiding it is not. A simple policy keeps test quarantine honest:
- Open a ticket first. The ticket names an owner and holds the trace that shows the failure.
- Set a deadline. Pick a number of days. When it passes, fix the test or delete it.
- Keep it visible. Quarantined tests should show in a report that someone reads, not vanish from the run.
- Keep the real signal. Do not raise the retry count across the whole suite to make one test pass. That hides every other flaky test too.
- Review the list weekly. A quarantine list that only grows means the policy is not working.
How to Talk About Flaky Tests in an Interview
Interviewers love this topic because it shows how you think. A strong answer follows the same path as this guide. Say how you detect flakiness (retries and the flaky status). Say how you gather evidence (a trace on first retry). Then name the root cause you found, and the fix, such as swapping a sleep for a web-first assertion.
Avoid the weak answer, which is "I added a retry". Explain that retries are a detector and the real fix is removing the cause. If you have a real story, use it. For more practice, try the Playwright interview questions, or build the habits in a clean setup with a Playwright test framework from scratch. AssertHired's Live Interview Coach lets you rehearse this kind of answer out loud, with follow-up questions.
Frequently Asked Questions
1. "Should I just increase the number of retries?"
No. Retries are a good detector, but a higher retry count hides flakiness instead of fixing it. Use them to find flaky tests, then fix the cause of each one. Keep the count low so real failures still show up.
2. "What does the flaky status mean in the Playwright report?"
It means the test failed on its first run but passed when retried. The test is unreliable even though the build is green. Treat every flaky result as a bug to investigate.
3. "Which trace setting should I use in CI?"
The Playwright docs recommend recording a trace on the first retry, with trace: 'on-first-retry'. Setting trace to 'on' for every test is performance-heavy. If you do not use retries, 'retain-on-failure' records every test and deletes the trace when the test passes.
4. "Why does my test pass locally but fail in CI?"
The usual causes are timing, data and environment differences. CI machines are often slower, so manual waits fail there first. Open the CI trace and compare it with a local run, and look at the Network tab for slow or failing requests.