The root cause: racing the app
Flakiness is almost always a timing race — the test checks for something before the app has finished doing it. The classic culprit is a fixed sleep ('wait 2 seconds then click'): too short and it fails on a slow run, too long and your suite crawls. The cure is to wait for conditions, never for time.
// Flaky: races the app
await page.waitForTimeout(2000);
await page.click('.submit');
// Reliable: web-first assertions auto-wait for the condition
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible(); // waits, retriesLocate by role and accessible name
Selecting by CSS class or DOM path couples the test to markup that changes constantly — and it's a big source of both flakiness and false breaks. Prefer role-based and text locators (getByRole, getByLabel, getByText). They're stable across restyles, they read like user intent, and they double as an accessibility check: if Playwright can't find the button by its role and name, neither can a screen reader.
Control the network
- Don't let tests hit live third-party APIs — mock them so a slow or flaky upstream can't fail your suite.
- Wait for the specific response your action triggers, not a blanket network-idle that's easy to get wrong.
- Seed a known state before each test (via API or fixtures) so tests are independent and repeatable.
- Make every test self-contained — no ordering dependencies, so one failure doesn't cascade.
The test of a good E2E suite is whether a red run means something. If the team's reflex is 'just re-run it,' the suite has already failed at its one job — trust.
Make failures debuggable in CI
When a test does fail in CI, you need to see why without reproducing locally. Playwright's trace viewer, screenshots and video on failure turn a cryptic red into a replay of exactly what happened. Run the suite in parallel to keep it fast, retry once in CI to distinguish a genuine failure from a rare infra blip, and quarantine (don't ignore) any test that retries too often.
Key takeaways
- Flakiness is a timing race — use web-first, auto-waiting assertions, never fixed sleeps.
- Locate by role and accessible name, not CSS/DOM paths, for stability and a11y coverage.
- Mock third-party network calls and seed known state so tests are independent and repeatable.
- Capture traces/screenshots on failure; a suite the team re-runs on red has lost its purpose.