Flaky tests are the slow leak in your automation pipeline. They don't fail loudly enough to demand immediate attention, but over time they do serious damage: developers start ignoring red builds, QA engineers add retry loops as a band-aid, and eventually someone says "oh that test is just flaky" — and nothing gets fixed. I've spent the last two years working on Playwright test suites across three different projects, and I've seen this pattern play out every time.
The frustrating part is that most flakiness in Playwright isn't random. Once you learn to read the failure signatures, you can almost always trace it back to one of a handful of root causes. What I want to do here is walk through the ones I've actually encountered — not the theoretical list from a documentation page — and show you the specific fixes that worked.
The Number One Culprit: Timing Assumptions Baked Into the Test Logic
Every Playwright flakiness investigation I've run eventually circles back to the same thing: somewhere in the test, someone made an implicit assumption about timing. Not always an explicit page.waitForTimeout(2000) — those are obvious. The sneaky ones are structural.
The most common pattern I see is clicking a button and immediately asserting on the result of the action that button triggers. Playwright's auto-waiting handles a lot, but it doesn't know what your application is supposed to do after the click. If clicking "Submit" triggers an async API call and then updates a table, Playwright will wait for the button to be clickable — it won't automatically wait for the table to reflect the new data.
The fix is to assert on the outcome you care about, not just the element state:
await page.getByRole('button', { name: 'Submit' }).click();
// Don't do this:
await expect(page.getByTestId('result-table')).toBeVisible();
// Do this:
await expect(page.getByTestId('result-table')).toContainText('New Record Added');
The second assertion is deterministic. The table being visible tells you almost nothing about whether the operation completed. The text content tells you the state you actually care about.
Another timing issue I hit repeatedly in one project was related to animations. We had a custom modal with a 300ms fade-in animation, and assertions against elements inside the modal were intermittently failing because the element was technically visible but not yet interactable at full opacity. Adding await page.waitForFunction(() => /* animation complete check */) was brittle. The cleaner fix was forcing prefers-reduced-motion at the browser context level:
const context = await browser.newContext({
reducedMotion: 'reduce'
});
This disables CSS animations in Chromium and made that entire class of failures disappear in Playwright 1.40+.
Network and API Dependency Flakiness
If your tests run against a real backend — even a staging environment — you're carrying external dependency risk into every single test run. I'm not going to argue here that you should always mock everything; there are valid reasons to test against real services. But you should be explicit about where that risk lives.
The flakiness pattern from network dependencies usually looks like this: a test passes locally 100% of the time, passes in CI most of the time, and fails in CI under heavy parallel load with a timeout or a stale element error. The root cause is typically that the backend took longer to respond than usual because ten other test workers are hammering it simultaneously.
There are two approaches I've used depending on project constraints:
Approach 1: Route interception for known slow endpoints. Playwright's page.route() is excellent for this. You intercept the specific API call, return a fixture response, and remove the timing variable entirely:
await page.route('**/api/reports/generate', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(reportFixture)
});
});
This is my preferred approach for tests where the backend logic is covered by unit or integration tests and I'm really testing the UI behavior.
Approach 2: Increase timeouts selectively with expect config. If mocking isn't appropriate, don't slap a global timeout increase on everything. Playwright 1.42 lets you configure timeouts per-assertion:
await expect(page.getByTestId('report-status')).toHaveText('Complete', {
timeout: 30000
});
Yes, this makes the test slower when it fails. That's acceptable. What's not acceptable is a 5-second timeout causing flakiness on a known-slow operation.
Selector Strategy: Why getByTestId Isn't Always the Answer
I used to think that migrating all selectors to data-testid attributes would solve our stability problems. It helped, but it didn't eliminate flakiness — and it introduced a different problem. When a testid selector fails, you get almost no diagnostic information about what the DOM looked like at the time of failure.
The selector flakiness I still see in Playwright suites usually falls into two categories:
Dynamic content that changes the DOM structure. A list that renders differently based on A/B test flags, a component that conditionally renders extra wrappers. The fix here isn't a better selector — it's asserting on state before interacting:
await expect(page.getByRole('list', { name: 'Search Results' })).toBeVisible();
const firstResult = page.getByRole('listitem').first();
await firstResult.click();
Waiting for the list to exist before targeting children eliminates an entire category of "element not found" flakiness.
Selector ambiguity in parallel test runs with shared state. This one is subtle. If two test workers navigate to the same page and both query for getByRole('button', { name: 'Delete' }), but the page state differs between them because they're using shared test data, you get inconsistent results. The fix is proper test data isolation — each worker should operate on its own data set, ideally created fresh per test via API calls in the beforeEach hook. I handle this with a lightweight test data factory pattern:
test.beforeEach(async ({ request }) => {
testData = await createTestRecord(request);
});
test.afterEach(async ({ request }) => {
await deleteTestRecord(request, testData.id);
});
This adds maybe 200–400ms per test but eliminates cross-test contamination entirely.
Making Playwright's Built-In Tooling Work for You
One thing I don't see teams using enough is Playwright's retry and trace infrastructure for diagnosing flakiness rather than just masking it. Running with --retries=2 in CI is reasonable as a safety net, but if you're not capturing traces on first retry, you're missing your best diagnostic tool.
In your playwright.config.ts:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
This configuration means that every time a test fails on the first attempt and then retries, you get a full trace file. Playwright's trace viewer shows you the DOM snapshot, network activity, and console logs at every action point. I've used this to find flakiness causes in under ten minutes that would have taken hours of local reproduction attempts.
Another underused feature is --repeat-each for local flakiness hunting. When you have a suspected flaky test:
npx playwright test tests/checkout.spec.ts --repeat-each=20 --reporter=line
Run it twenty times in a row. If it fails twice in twenty runs, that's a 10% failure rate — significant enough to investigate, but rare enough that you'd never catch it with normal single-run CI.
For flakiness that only manifests in parallel runs, use --workers=4 locally to simulate CI conditions while keeping it fast enough to iterate on.
Treating Flakiness as a First-Class Defect
The cultural fix matters as much as the technical one. On the last project where I led QA, we instituted a rule: any test that fails in CI without a corresponding code change gets filed as a defect with P2 priority, not retried and forgotten. The first time this felt bureaucratic. After two months, our flakiness rate dropped from around 8% to under 1%.
Flaky tests are defects. They have root causes. They can be fixed. The teams that accept them as an inevitable fact of automation life are the ones who end up with test suites nobody trusts — and a CI pipeline that exists mostly as theater.
Start with traces. Look at what the DOM actually contained at the moment of failure. Trace 90% of flakiness back to timing assumptions or shared state, because that's where it almost always lives. Then fix it properly instead of adding another retry.
Abdulkadir Akyurt is a QA Engineer and Test Automation Specialist.
Follow me on Medium for more articles · Connect on LinkedIn · Instagram · X / Twitter · Visit abdulkadirakyurt.com