Test Automation
How to Fix Flaky Playwright Tests: Wait on the Network, Not the Clock
The fix for flaky Playwright tests is to stop waiting on the clock and wait on the network: replace every fixed timeout with a wait for the specific response or visible state that proves the application has finished its work. A sleep is a guess about speed. A wait on the real event is correct on a slow day and on a fast one.
Most flaky Playwright tests are not a tool problem. They are a design problem in the suite: waits that guess, locators that depend on markup nobody promised to keep stable, and test data built inline in every spec. This guide covers the causes, the patterns that remove them, and a checklist you can run against your own suite. It draws on a real end-to-end test automation engagement on a multi-tenant commerce operations platform.
Why Playwright tests go flaky
When a nightly run goes red, either the product is broken or the test is. A flaky suite blurs the difference, and that is its real cost. Somebody has to open each failure and decide which it is, usually by hand and usually before the working day starts. After enough mornings like that, people stop trusting a red result and start rerunning it.
A few habits produce most of the noise.
- Arbitrary timeouts. A fixed pause passes when the application is quick and fails when it is not. Raising the number makes the suite slower without making it correct.
- Locators tied to implementation details. Identifiers generated by a forms library or a data grid change when the library does. The test fails for a reason no user would ever notice.
- Test data built inline. When every spec invents its own records, with random values scattered across files, failures are hard to reproduce and the setup cannot be reused.
What to wait on instead of timeouts
Playwright already retries most assertions about what is visible on the page, so the starting rule is to lean on that and never add a pause on top of it. The harder case is an action whose outcome is not visible straight away: a save, a delete, or a bulk operation that talks to the backend.
For those, wait for the response of the exact request the action triggers. In a GraphQL application, that means waiting on the named operation rather than on the address, because many operations usually travel to the same endpoint. If you want to wait for a GraphQL response in Playwright reliably, key the wait on the operation name, then assert on what came back.
A save usually has more than one step, so wait for each in order: the mutation, the success notification, and then the refetch that refreshes the list. Waiting on the mutation proves the request returned. Waiting on the notification proves the interface reacted. Waiting on the refetch proves the screen now shows the saved state, so the next step starts from a settled page.
Keep the waits inside a Playwright page object model
Scattering network waits across specs trades one problem for another. A Playwright page object model gives each waiting rule a single home. The page object knows how to save and what "saved" means, so a spec reads as intent: create the record, confirm it appears.
Layering helps. A base page holds the shared behavior. Drawer and wizard classes extend it for the interface patterns that recur, and each can have a variant that is aware of the GraphQL operations behind it. When a rule about waiting changes, you change it once.
There is a trade-off, and it is worth stating plainly. Page objects that wait on operation names have to know those names, so a frontend engineer who renames a query breaks the tests loudly. That is the point. A loud break takes an hour to fix. A silent flake costs a team its trust in the suite, and that trust does not come back easily.
Use test data builders instead of inline data
Test data builders are small fluent objects that assemble a valid record one named step at a time. The same builder can seed data through the API before a test starts and fill the form during the test, so setup and interaction describe the same record. Builders pay off most in a suite that runs one generic create, update and delete scenario against many different entity drawers. Each entity declares its own metadata on the drawer class, and the shared scenario builds data through the builder instead of through scattered random-data helpers.
Pair builders with locators that survive a redesign. Prefer the role and accessible name that a user or assistive technology would see, and scope a step by its ARIA region name rather than by generated identifiers. These are among the Playwright best practices that turn a frontend migration into an annoyance instead of a rewrite.
What this looked like on a real engagement
On a multi-tenant commerce operations platform, features were shipping faster than they were being automated. The specs that did exist waited on arbitrary timeouts, built their data inline, and broke every time the frontend moved to a new forms library or data-grid version. Each nightly run mixed genuine defects with test-side timing failures, and somebody sorted one from the other by hand every morning.
We owned automation for several areas of the application and built the suite in layers: eighty-five end-to-end test cases and forty-eight page-object classes across forty-six files, with a base page, drawer and wizard classes, and a GraphQL-aware variant of each. Fluent data builders for schedules, item availability, order-type settings and store groups seeded data through the API and filled the interface. Every create, update and delete waits on the named GraphQL operation and its refetch, never on a sleep.
The decision that mattered was the trade-off described above. We made page objects know operation names on purpose, accepting that a renamed query would fail loudly, because a loud break is cheap and a silent flake is not.
The suite was built to survive two frontend migrations. Eighty-five test cases and forty-eight page-object classes remain in use on the integration branch. When a forms-library migration removed the old store-list query and its element identifiers, four bulk-wizard tests broke. Scoping the affected step by its ARIA region name brought all four back, and the fix survived the migration after that one too. For the full account, you can read the full case study, or browse the other Kaycore case studies.
A checklist for fixing flaky Playwright tests
- No fixed sleeps anywhere. Every wait names a response, a notification or a visible state.
- Every save waits for the mutation, then the success notification, then the refetch, in that order.
- Locators use role and accessible name, scoped by region, rather than generated identifiers.
- Page objects own the waiting rules, so specs describe intent and nothing else.
- Test data comes from builders, and the same builder can seed through the API or fill the form.
- Every red nightly failure is sorted into a product defect or a test fix, never left as rerun and hope.
Waiting is only part of nightly stability. The rest is what your tests leave behind in a shared environment, which we cover in flaky tests in CI and how to take over a red pipeline. If you would like to see how we report findings on an application, look at a sample quality audit, or see how we work with teams on our services page.
