Flaky Playwright tests are the productivity killer in test suites. They pass one run, fail the next, and train your team to ignore test failures altogether, which is exactly when real bugs slip through.
This guide covers the root causes of flaky Playwright tests, how to reproduce and debug them locally, and the fixes that actually work. You'll also learn how to configure retries properly and prevent flakiness before it starts.
What causes flaky Playwright tests
Flaky tests pass and fail intermittently under identical code conditions. You run the same test on the same commit, and sometimes it works, sometimes it doesn't. While Playwright has robust built-in auto-waiting, flakiness typically stems from timing race conditions, shared state between tests, or external API dependencies.
The frustrating part? Flaky tests erode trust in your entire test suite.
When tests fail randomly, teams start ignoring failures altogether. Real bugs slip through because everyone assumes the red build is "just flakiness again."
Timing assumptions and race conditions
Most flaky Playwright tests come down to timing. The test assumes an element loads instantly, but the page hasn't quite caught up yet.
- Hardcoded waits: Using
page.waitForTimeout(3000)introduces arbitrary delays that are either too short (causing failures) or too long (slowing your suite down for no reason) - Missing await statements: Async operations completing out of order cause actions to fire before the page is ready
- Animation interference: Elements might be visible but still animating, so clicks land on the wrong target or miss entirely
Network and API dependencies
When your test relies on a live backend, you're introducing variables completely outside your control. Server load, network hiccups, rate limiting, or even a slow database query can turn a passing test into a failing one.
The test itself might be perfectly written. But if the API takes 50ms one run and 500ms the next, you'll see intermittent failures that have nothing to do with your code.
Brittle selectors and DOM changes
Selectors that break when the UI changes are a constant source of maintenance headaches. Auto-generated CSS classes, deeply nested paths, and IDs that change between builds all contribute to tests that worked yesterday but fail today.
This is especially common in fast-moving codebases. A designer tweaks a button style, the CSS class name changes, and suddenly three tests fail even though the actual functionality is fine.
CI environment differences
Your local machine is typically faster and more powerful than CI runners. A test that passes in 200ms locally might timeout in a resource-constrained container.
CPU and memory constraints, headless versus headed mode, and different browser versions between environments can all cause tests to behave differently. The test isn't flaky on your machine because your machine is fast enough to hide the race condition.
Test isolation and shared state
Tests that depend on execution order or share database records, cookies, or storage cause flakiness. This often surfaces when running tests in parallel. Tests that passed sequentially start stepping on each other because they're both trying to use the same user account or modify the same database row.
How to reproduce flaky tests locally
Fixing playwright flaky tests requires reproducing them first. Many developers struggle here because tests pass locally but fail in CI, making the problem feel random.
The key is simulating the conditions that cause failures. Once you can reliably trigger the flake, you can actually debug it.
1. Throttle CPU to simulate CI conditions
CI runners typically have less CPU power than your development machine. You can use Playwright's slowMo option or Chrome DevTools CPU throttling to mimic slower execution.
This often exposes race conditions that are invisible at full speed. When everything runs slower, timing-dependent bugs become obvious.
2. Run tests in headed mode with slow motion
Setting headless: false and adding a slowMo value lets you visually observe test execution. You might notice elements flickering, buttons being clicked before they're fully rendered, or unexpected page states.
Sometimes watching the test run is all it takes to spot the problem.
3. Repeat tests with the --repeat-each flag
Running a single test once doesn't prove it's stable. Use npx playwright test --repeat-each=100 to run the same test 100 times consecutively. Learn more about running and debugging tests in the official Playwright docs.
If it fails even once out of 100 runs, you've confirmed flakiness. Now you have a reproducible way to test your fix.
Debugging with Playwright Trace Viewer
Trace Viewer is Playwright's built-in debugging tool, and it's the single most effective way to diagnose flaky tests. See our ultimate guide to Playwright Trace Viewer for a deep dive. It captures screenshots, DOM snapshots, network logs, and console output for each action in your test.
To enable tracing, add this to your playwright.config.ts:
use: {
trace: 'on-first-retry',
}
After a failure, open the trace file with npx playwright show-trace trace.zip. The visual timeline often reveals exactly why a test failed when logs alone leave you guessing.
Look for timing gaps between actions, failed network requests, or unexpected DOM states at the moment of failure. You'll see exactly what the page looked like when the test tried to click that button and missed.
Fix timing issues with Playwright auto-waiting
Playwright has built-in auto-waiting that eliminates most timing flakiness when used correctly. Unlike Selenium, which requires explicit waits for nearly every action, Playwright automatically waits for elements to be actionable before interacting with them. See Playwright vs Selenium for a detailed comparison.
How auto-waiting works
When you call page.click(), Playwright automatically waits for the element to be visible, stable, enabled, and receiving events. This means you rarely need explicit waits because the framework handles timing for you.
The catch is that auto-waiting only works when you use Playwright's locator methods correctly. If you're grabbing elements with raw selectors and then acting on them, you might bypass the auto-waiting behavior.
Using waitFor for dynamic content
For elements that appear asynchronously, use locator.waitFor() instead of hardcoded delays:
| Approach | Example | Result |
|---|---|---|
| ✅ Recommended | await page.getByRole('button').waitFor({ state: 'visible' }) | Waits only as long as needed |
| ❌ Avoid | await page.waitForTimeout(3000) | Always waits 3 seconds, still might fail |
The first approach adapts to actual page behavior. The second approach is a guess that will eventually be wrong.
Setting appropriate timeouts
Global timeouts in playwright.config.ts control how long Playwright waits before failing. Setting timeouts too high masks real issues. Setting them too low causes false failures.
A typical configuration uses 30 seconds for test timeout and 5 seconds for assertions. Adjust based on your application's actual performance characteristics.
Fix network flakiness with mocking and stubbing
Removing external dependencies makes tests deterministic. Playwright's network interception lets you control exactly what your tests see, regardless of what the actual backend is doing.
Mock API responses with route handlers
Use page.route() to intercept requests and return fake responses:
await page.route('**/api/users', route => {
route.fulfill({ json: { users: [{ name: 'Test User' }] } });
});
This removes backend variability entirely. Your test always sees the same data, every time.
Stub external services in test fixtures
For more complex scenarios, Playwright fixtures let you set up consistent mock servers that persist across multiple tests. This approach works well when many tests share the same mocked endpoints and you don't want to repeat the setup code.
Fix brittle selectors with stable locators
Playwright's locator API is designed to resist flakiness. Your selector strategy directly impacts how often tests break when the UI changes. Our Playwright locators guide covers getByRole, getByText, and more.
Prefer getByRole and getByText
User-facing locators survive CSS refactors because they target what users see, not implementation details:
- Buttons by accessible name:
page.getByRole('button', { name: 'Submit' }) - Elements by visible text:
page.getByText('Welcome back') - Form fields by label:
page.getByLabel('Email address')
When a designer changes the button's CSS class, the test keeps working because it's looking for a button labeled "Submit," not a specific class name.
Use data-testid as a fallback
When semantic locators aren't possible, data-testid attributes provide stable anchors: page.getByTestId('login-form'). Developers explicitly maintain these attributes, so they survive refactors.
Avoid XPath and fragile DOM paths
CSS selectors with deep nesting or auto-generated classes break with every UI update. If your selector looks like .container > div:nth-child(3) > button.btn_x7y2, it's only a matter of time before it fails.
Configure Playwright retry settings
Retries are a safety net, not a fix. Playwright can automatically re-run failed tests, which helps CI pipelines stay green while you investigate root causes. See the official Playwright retries documentation for configuration details.
Setting retries in playwright.config.ts
retries: process.env.CI ? 2 : 0
CI typically benefits from one or two retries, while local development surfaces failures immediately so you can fix them right away.
When retries mask deeper problems
If a test consistently requires multiple retries to pass, it needs fixing rather than more retries. High retry counts hide real flakiness and slow down your pipeline.
Track which tests require retries and prioritize fixing them. A test that passes on the third try every time is still a flaky test.
Prevent flaky Playwright tests before they start
Prevention is easier than debugging Playwright flaky tests. A few practices at authoring time stop flakiness before it starts. See 5 best practices for Playwright E2E testing for more tips.
Write deterministic tests
- Seed test data: Don't rely on existing database state. Create what you need for each test.
- Control time: Mock dates and times when testing time-sensitive features.
- Avoid random values: Use fixed inputs or seeded randomness for reproducibility.
Isolate tests and avoid shared state
Each test sets up and tears down its own state. Use test.describe.configure({ mode: 'serial' }) only when tests genuinely depend on each other. Otherwise, keep them independent.
Run parallel tests safely
Playwright runs tests in parallel by default. Tests that conflict over shared resources will fail intermittently. Design tests to use unique data so they can run simultaneously without stepping on each other.
Eliminate flakiness with AI-powered test automation
Even with best practices, Playwright flaky tests from selector-based approaches require ongoing maintenance as UI evolves. Every DOM change risks breaking tests, and someone has to update them.
Momentic takes a different approach. Instead of brittle CSS selectors, tests describe what to interact with in natural language. The AI finds the right element even after DOM changes, and self-healing locators adapt automatically without manual updates.
Talk to our team to see how intent-based testing eliminates flaky tests at the source.