Home / Blog / Flaky Playwright tests
Flaky Playwright tests: how to find the cause and fix it
Run the failing spec alone, twenty times, retries off:
npx playwright test settings.spec.ts --repeat-each=20 --retries=0 --workers=1.
If it fails on your laptop, the cause is the test or the product. If it fails only on CI, it is
the environment or leftover data. Record a trace of a failing run and read that before you
change a line.
An experiment settles which of the four causes below you have, and the error string does not. The command above is its first step, and every fix on this page depends on what that run does.
Why is my Playwright test flaky?
Playwright's definition is narrower than the one people reach for in standups, and it is the one
the reporter counts. The retries guide sorts every
result into three buckets, and the middle one is "flaky" - tests that failed on the first
run, but passed when retried. That is a category in the run summary: a run that ends
1 flaky is telling you a spec went red and then green with nothing changed in
between.
The causes behind that line sort into four, and this is the same set, in the same order, that a flake register uses when every intermittent spec in a suite has to carry one.
- Locator strategy. The selector resolved to a different element, or to more than one. The symptom is a click that lands somewhere unexpected on a busy page, or an error naming a strict mode violation.
- A real race in the product. The application itself is intermittently wrong. The symptom is a failure you can also produce by hand, given enough goes.
- Test data. The test depended on state another test or another run left behind. The symptom is a spec that passes on its own and fails inside the full suite, or one that fails on the second run of the day.
- Environment. CI capacity, a shared deployment, a third-party service, clock or timezone. The symptom is green on your machine and red on the runner, or red only after somebody else deployed.
That order is the register's order and nothing more. It is not a ranking by how often each cause turns up, and this page does not offer one.
How do you tell which one you have?
The error string is the last thing to look at, not the first. A failure that reads
Timeout of 30000ms exceeded. is compatible with all four causes and so diagnoses
none of them. Three moves, in this order, cut the search space before you read a word of the
failure.
Reproduce it on its own
Every sample on this page is written against Playwright 1.62.
npx playwright test settings.spec.ts --repeat-each=20 --retries=0 --workers=1
Playwright 1.62 · CLI
Put your own spec in place of settings.spec.ts. Each flag takes one thing out of
the picture, and the CLI reference is exact about what they do. --repeat-each will
"Run each test N times (default: 1).", which turns one unreliable observation into
twenty. --retries is the "Maximum retry count for flaky tests, zero for no
retries (default: no retries).", and zero here means every failure stays visible instead of
being absorbed. --workers takes the "Number of concurrent workers or percentage
of logical CPU cores, use 1 to run in a single worker (default: 50%).", and one worker
removes every other spec in the suite from the run.
The combination is ours; no page on playwright.dev recommends these three together. When you think the spec is fixed, run the same command again, with the same count, on the same machine. A closing number is worth something only if it was taken the way the opening one was.
Run the same command on CI
Push a branch that runs exactly that command and nothing else. It costs two lines of pipeline and it halves the problem: a spec that fails on your laptop is the test or the product, and a spec that passes twenty times locally and fails on the runner is the environment or the data.
Read the trace, not the stack trace
A stack trace tells you which line gave up. A trace tells you what the page looked like when it did, which request was still in flight, and what the locator matched. Turn it on before you need it and it will be there on the run that fails at four in the morning.
| What you observe | Which cause | What to open next |
|---|---|---|
Fails on your laptop under --repeat-each=20 | Locator strategy, or a real race in the product | The trace's Snapshots pane for the failing action |
| Passes alone, fails inside a full run | Test data | The other specs that touch the same records |
| Passes twenty times locally, fails on CI | Environment | The trace's Network pane, and the workers setting |
| Fails at one time of day, or in the days after a clock change | Environment | The Clock API and timezoneId |
| The error names a strict mode violation | Locator strategy | The locator, and not the wait around it |
| Reproduces against the deployed app with the spec deleted | A real race in the product | The request sequence, and whoever owns the application |
What does the trace show you?
Nothing gets recorded unless the config says so. The trace viewer guide sets it on the first retry, which is the setting that costs least and still catches the failure you care about:
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: 1,
use: {
trace: 'on-first-retry',
},
});
Playwright 1.62 · TypeScript
The documented description of that value is "Record a trace only when retrying a test for
the first time." The config option takes five values in total, while the
--trace CLI flag takes seven, adding retain-on-first-failure and
retain-on-failure-and-retries. Take config values from the config option and CLI
values from the flag; the two lists are not the same list.
Open one with npx playwright show-trace path/to/trace.zip. The reading order that
gets you to a cause fastest starts in Actions, which shows the locator used for
every action and how long each one took. Click the action that failed, then compare its
Before and After snapshots: that pair is where a locator which
matched the wrong element stops being a theory. There is a third snapshot per action, taken at
the moment of the input, and it highlights the DOM node together with the click position
Playwright used. Network and Console both filter to
the selected action when you double-click it, so a response that arrived late sits in the same
view as the action that needed it. Errors puts a red line on the timeline where
the failure happened.
That reading order is ours; the documentation lists the panes and leaves you to sequence them. There are eleven of them, and each repays a slower tour than this one, which is the subject of reading a Playwright trace.
Do retries tell you anything?
They do, and it is not the thing they are usually turned on for. When a test fails, "Playwright Test will discard the entire worker process along with the browser and will start a new one." The retry therefore begins in a browser that has never seen your application. Our inference from that, and it is labelled as ours: a spec that fails first and passes second is often passing because it got clean state, which makes the retry a report on what that spec depends on and does not own.
Set --retries=0 while you are diagnosing, so nothing gets absorbed. In the
pipeline, --fail-on-flaky-tests will "Fail if any test is flagged as flaky
(default: false).", which puts the flake back into the exit code instead of leaving it
inside a green build as one line of summary text.
Count the runs, because the option is named for the retries and not for the total. With
retries: 2 a spec can run three times before the build calls it failed: one first
run and two retries. With retries: 3 it is four.
Playwright 1.62 added a second dial. retryStrategy "Controls when failed tests
are retried. Defaults to 'immediate'.", and that default retries a failure as
soon as a worker is free, interleaved with the rest of the run. Its other value,
'isolated', is documented as running retries "at the end, after all other tests
have finished, one by one in a single worker." You pay for that in total run time and you
get back a retry that is not competing with the suite around it.
Why does it pass locally and fail in CI?
Your laptop runs one spec against an idle machine. The runner runs the whole suite against a machine it shares. Playwright's CI guide is direct about it: "We recommend setting workers to "1" in CI environments to prioritize stability and reproducibility. Running tests sequentially ensures each test gets the full system resources, avoiding potential conflicts." The default is a percentage of logical CPU cores, so a runner with four of them starts two workers and each browser gets half a machine.
A slower machine does not usually invent a race. It widens one that was already in the code until the test can see it, which is why this split can end up pointing at the product. Prove it from the trace's timings. The same action on the same page taking far longer on CI is a resource story, and a response that arrives in a different order is something else.
Two other candidates live here. A deployment shared with another team can move underneath a run. A third-party call can be fast from your desk and slow from a runner in another region. Both leave a timestamp in the Network pane.
If the answer is "it only fails in WebKit", be careful what you conclude from it. Playwright's WebKit is a build of the engine, and it is not Safari on an iPhone. A flake that reproduces only on a physical iOS device cannot be chased inside Playwright at all, and teams that need the handset keep a small device-cloud suite for it.
How to fix a flaky locator
Locators in Playwright are strict, and the guide states the consequence: every operation that implies a target element "will throw an exception if more than one element matches". When the DOM grows a second matching node on a bad day, a spec that had worked for months starts naming a strict mode violation. The quieter failure in this class is a long CSS chain that keeps matching after a refactor and matches the wrong thing.
import { test, expect } from '@playwright/test';
test('opens the notification settings', async ({ page }) => {
await page.goto('/account/settings');
await page.locator('#root > div.panel > div:nth-child(3) > button').click();
expect(await page.getByText('Notifications').isVisible()).toBe(true);
});
Playwright 1.62 · TypeScript
The chain is tied to a DOM structure nobody promised to keep. The check under it is worse: the
best-practices guide is blunt about that shape, saying that with assertions such as
isVisible() "the test won't wait a single second, it will just check the locator
is there and return immediately." A web-first assertion does that waiting for you:
"By using web first assertions Playwright will wait until the expected condition is
met." The default timeout for assertions is five seconds.
import { test, expect } from '@playwright/test';
test('opens the notification settings', async ({ page }) => {
await page.goto('/account/settings');
await page
.getByRole('listitem')
.filter({ hasText: 'Notifications' })
.getByRole('button', { name: 'Edit' })
.click();
await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible();
});
Playwright 1.62 · TypeScript
The rewrite narrows by content rather than by position, so a fourth row in the list does not
move the target, and the assertion now waits for the heading it needs. Reach for
filter() before nth(): an index is a promise about ordering that your
product team never made. Which locator suits which markup is a longer argument, and it is made in
the guide to choosing locators.
How to tell a product race from a test bug
Flakiness caused by a real race in the product is not a test bug, and no change to the test fixes it. That is the limit of everything above, and it is the first thing to rule in or out: every hour spent rewriting a locator against an application defect ends with the same failure and a worse test.
The shapes are familiar once you have met them. A request that sometimes lands after the component has already redrawn, so the value on screen is one state behind. A submit button that can be pressed twice, because only the response disables it and the response is late. An optimistic update that loses to its own server response and reverts. All three are intermittent in a browser driven by a person, and more intermittent in a browser driven fast.
Open the trace, select the failing action and read the Network pane filtered to it: if the
response the assertion needed is timestamped after the render that should have consumed it, the
ordering is wrong in the application. Then run the same sequence against the deployed app with
the spec out of the picture, by hand or with a short script that only calls the API. Then, if you
want something countable to take to a standup, point --repeat-each at a spec that
does nothing but the two steps that race.
You end up with a reproduction that the team owning the application can act on: the trace, the run it failed on, and the request sequence that produced it. When the list of specs in that state runs past what one person can work through before the next release, that is the shape of a flaky Playwright test remediation engagement, where every intermittent spec is ranked and given one of the four causes, and the ones that turn out to be races go back as reproductions.
How to fix a test that depends on data it does not own
The parallelism guide draws the line in the right place: "Playwright runs tests in separate worker processes, each with its own isolated BrowserContext, so cookies, storage and in-memory globals are already isolated. Flakiness comes from state that lives outside a single test." Your database is outside a single test. So is the file the suite writes its fixtures into, and so is yesterday's run.
The classic version costs nothing to write and years to find. One spec creates account 4102, another asserts on it, and the pair passes until the workers get scheduled the other way round or somebody runs the second file alone.
import { test, expect } from '@playwright/test';
test('the account page shows the saved address', async ({ page }) => {
await page.goto('/accounts/4102');
await expect(page.getByText('14 Bridge Street')).toBeVisible();
});
Playwright 1.62 · TypeScript
A spec that owns its own row cannot lose that race. Derive the identifier from
testInfo.testId, which the parallelism guide recommends for exactly this, create the
record through the API, and delete it on the way out so the next run does not inherit it.
import { test, expect } from '@playwright/test';
test('the account page shows the saved address', async ({ page, request }, testInfo) => {
const accountId = `account-${testInfo.testId}`;
await request.post('/api/accounts', {
data: { id: accountId, address: '14 Bridge Street' },
});
await page.goto(`/accounts/${accountId}`);
await expect(page.getByText('14 Bridge Street')).toBeVisible();
await request.delete(`/api/accounts/${accountId}`);
});
Playwright 1.62 · TypeScript
It is slower per spec, and it buys a suite you can shard, filter and re-run in any order. The same rule covers files: two specs writing to one path collide the moment they run at the same time, so give each of them a path built from the same identifier.
How to fix flakiness that comes from the environment
Each source below has a control you can take.
Clock and timezone
A test that reads a date off the page and compares it against new Date() is a test
that fails at midnight, at a month boundary, and in the days after a clock change. Take the clock
away from it:
import { test, expect } from '@playwright/test';
test('the dashboard renders the current time', async ({ page }) => {
await page.clock.setFixedTime(new Date('2024-02-02T10:00:00'));
await page.goto('/dashboard');
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:00 AM');
});
Playwright 1.62 · TypeScript
The Clock API documents seven methods — install, setFixedTime,
setSystemTime, pauseAt, fastForward, runFor
and resume — and the guide recommends starting at setFixedTime. Pin the
browser's locale and timezone in config too, so a runner in another region formats dates the way
your assertions expect:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
locale: 'en-GB',
timezoneId: 'Europe/Paris',
},
});
Playwright 1.62 · TypeScript
One caveat gets filed as "the fix did not work". The
emulation guide says "Note that this only affects the browser timezone and locale, not the
test runner timezone." Date formatting that happens in your spec file, on the Node side,
does not move with it; the TZ environment variable is what moves that.
Third-party services
A payment sandbox, a maps tile server or an analytics beacon answers at a speed nobody promised
you. Intercept the call and control the response, with page.route() and
route.fulfill(). That turns a network variable into a fixture, and it makes the
failure reproducible on the day you need it to be.
CI capacity
Setting workers to 1 on CI removes contention between your own specs, and you pay
for that in wall-clock time. Where the suite is too long to run sequentially, shard it across
jobs so each job still gets a machine to itself. Sizing the runners is
an infrastructure decision and not a testing one: Playwright measures one browser doing one
thing, it is not a load testing tool, and this page does not turn into performance advice.
If the flaky spec is Appium driving an iOS or Android app, none of this transfers. Playwright drives browsers. It emulates a mobile browser — a viewport, a user agent, touch input — and not the application, the store build or the handset.
What raising the timeout costs you
Raising the timeout works. So does raising the retry count. Both turn the build green this afternoon and both leave the failure exactly where it was.
Start from the numbers you would be changing. A test gets 30,000 ms by default and an assertion gets 5,000 ms; an action and a navigation have no timeout at all unless one is set. Take a spec from the default 30,000 ms to 90,000 ms and the failure you were chasing costs 90 seconds of runner time every time it happens instead of 30, three times as much, and it happens as often as it did before. Meanwhile the meaning of a green build has changed, and nobody was in the room when it did.
The second cost lands on a person. The next engineer to open a red build has one more reason to
believe it is noise, and presses re-run. page.waitForTimeout() is the same move with
a smaller blast radius and a longer life: a fixed pause, chosen on a day when somebody was
guessing, which stays in the file long after the page it was guessing about has been rewritten.
The API reference marks the method Discouraged and is blunt about why: "Never wait
for timeout in production. Tests that wait for time are inherently flaky." Which timeout
fired, and how the expect retry interval differs from the action
timeout, is worked through in
auto-waiting and timeouts.
Where you genuinely have no time this week, take the spec out of the default run in a way that leaves a mark:
import { test, expect } from '@playwright/test';
test('checkout applies the promo code @quarantine', async ({ page }) => {
await page.goto('/checkout');
await expect(page.getByText('Discount applied')).toBeVisible();
});
Playwright 1.62 · TypeScript
Then run the suite with npx playwright test --grep-invert @quarantine. Tags must
start with an @ symbol, and the tagged spec stays in the file and in the report,
which is the difference between a test somebody comes back to and a test that has gone.
Questions
Why does my Playwright test pass locally but fail in CI?
Usually because the runner is slower and busier than your laptop, which makes a race that was already in the code visible for the first time. Playwright's CI guide recommends setting workers to 1 there, so that each test gets the full system resources, where the default is a percentage of the logical CPU cores it finds. Run the same isolated command in both places before you change anything: a spec that fails on your laptop is the test or the product, and a spec that fails only on CI is the environment or data left behind by another run.
Should I turn on retries for flaky tests?
Yes, as an instrument. A failed test makes Playwright discard the whole worker process along with its browser and start a new one, so a spec that only passes on the retry got a clean browser and clean state, and that is information about what it depends on. Watch what retries do to the meaning of green. With retries set to 2 a spec can run three times before the build calls it failed, one first run and two retries, and the summary line six weeks later does not say which specs used them. The flag --fail-on-flaky-tests puts the flake back into the exit code.
How do I know the flakiness is a bug in my app and not in my test?
Take the test out of it. Open the trace of a failing run, select the action that failed, and read the Network pane filtered to that action: if the response the assertion needed arrived after the render that was supposed to consume it, the ordering problem belongs to the application. Then reproduce the same sequence without Playwright driving it, by hand or with a short script against the same deployment. Something that reproduces with the spec deleted is not repairable inside the spec, and no locator, wait or fixture change closes it.
Can I quarantine a flaky test until I have time?
Yes. Put an @-tag in the test title and exclude that tag from the default run with --grep-invert, which leaves the spec in the file and in the report where somebody can still count it. test.fixme() is the other route, and Playwright will not run the test at all. A quarantined spec is coverage somebody has to come back to; a deleted one is coverage nobody will remember losing.
Which spec, and what did the trace show?
Send the name of the spec that keeps going red and what the Actions pane showed on the run that failed. That is enough for us to name which of the four you are looking at. If there are more of them than one person can work through, we can run the suite as it stands, on commits where the product code did not move, and rank what comes out of it.