Playwright Flaky E2E — waitForSelector Isn't Enough
CI is red on retry two, green on retry three. Locally it never fails. Someone “fixed” it with waitForTimeout(3000). That is not a fix; that is a sleep tax. Playwright flakes usually come from asserting on the DOM before the app finished navigating, fetching, or hydrating — and from tests that share state across parallel workers. The cure is waiting for the product event that makes the UI true, not padding milliseconds until the race usually wins.
Prefer assertions that wait for you
Playwright’s expect(locator).toBeVisible() auto-retries. Raw waitForSelector plus an immediate click still races if the node exists but is covered, disabled, or about to re-render away.
// brittle
await page.waitForSelector('[data-testid="save"]');
await page.click('[data-testid="save"]');
// better
const save = page.getByTestId("save");
await expect(save).toBeEnabled();
await save.click();
await expect(page.getByText("Saved")).toBeVisible();
Use user-facing locators (getByRole, getByLabel) when you can — they encode accessibility and reduce CSS-churn flakes. Test ids are fine for ambiguous icons; they are not a free pass to click before enabled.
Network: idle is not a silver bullet
waitForLoadState('networkidle') fails on apps with analytics websockets or polling. Prefer waiting for the specific response that gates the UI:
const [res] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/api/invoice") && r.ok(),
),
page.getByRole("button", { name: "Refresh" }).click(),
]);
await expect(page.getByTestId("invoice-total")).toHaveText("$42.00");
For SPA navigations, assert the destination, not merely the load event:
await page.goto("/settings");
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
Do not assume DOMContentLoaded means React finished hydrating. Client routers can paint a shell first; your assertion should name the content that proves the route is ready.
Parallel workers and shared fixtures
Default workers greater than one will collide if tests mutate the same user, truncate the same database, or share a singleton email inbox.
| Symptom | Fix |
|---|---|
| Random auth failures | Unique user per worker (workerIndex in fixture) |
| “Already exists” errors | Namespaced test data |
| Order-dependent passes | Seed in beforeEach; never rely on previous tests |
| File upload races | Unique filenames |
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
});
Retries hide flakes — keep them in CI for signal, but track retry rate. A test that always needs retry two is still broken. Quarantine only with a ticket; silent quarantine gardens rot.
Timing, animations, and strict mode surprises
Disable or shorten animations in test builds. Clicking a fading button flakes. Prefer page.clock where applicable, or a test-only stylesheet with * { animation: none !important }.
React Strict Mode double-invoking effects can make “clicked once, submitted twice” bugs show only under load. Assert server-side that only one resource was created, or debounce in the app. E2E should catch double submits; it should not paper over them with sleeps.
Also watch iframes and open shadow roots: locators that work on the main frame fail mysteriously when the control moved into a portal. Traces make this obvious if you look at the hierarchy instead of adding timeouts.
Debugging a flake without superstition
- Run
npx playwright test --repeat-each=20 path/to/spec.ts. - On failure, open the trace:
npx playwright show-trace trace.zip. - Look at the timeline: click before response? wrong frame? detached node?
- Add a targeted
waitForResponseorexpect— not a blanket timeout. - If only CI fails, compare CPU, worker count, and seed data isolation before blaming “the cloud.”
Anti-patterns to delete
waitForTimeoutas synchronizationpage.reload()until green- Relying on default timeouts without knowing what is slow
- Sharing one authenticated storage state across mutating tests without isolation
- Catching failures and retrying inside the test body instead of fixing the wait
Flakes are race conditions with a marketing name. Make the wait condition match the product event that makes the UI true — then parallelism stops being scary, and CI red builds become diagnosable instead of ritualistically retried.