# Replace Browser-Test Sleeps with Observable Conditions

> Diagnose browser-test races, then wait for the visible condition that makes the next user action or assertion valid instead of adding longer sleeps.

Canonical URL: https://www.devobs.io/articles/flaky-browser-test-waits/
By: Samira Haddad
Published: 2024-04-05T03:09:14.655Z
Updated: 2026-09-06T08:31:04.426Z
Section: Architecture

A longer sleep makes a browser test slower without telling it when the application is ready. Wait for the observable condition that makes the next user action valid: a button becomes enabled, a status appears, a response changes the page, or navigation reaches the intended state. Then use traces or equivalent failure evidence to find which missing condition caused the flake.

## Identify the race before changing timeouts

A click can race with element attachment, visibility, stability, event handling, asynchronous rendering, or an overlay. The next assertion can race with a request, [background job](https://www.devobs.io/articles/permission-changes-during-background-jobs/), animation, or stale client cache. Shared accounts and reused data introduce a different race between tests. Classify the failure from trace evidence; one global timeout cannot fix all of them.

[Playwright actionability documentation](https://playwright.dev/docs/actionability) explains that actions auto-wait for conditions such as a locator resolving uniquely, visibility, stability, receiving events, and enabled state. If a click times out, inspect which condition failed. Forcing the action bypasses checks and often turns a useful failure into a misleading later assertion.

Use locators based on role, accessible name, label, or stable test ID. A selector tied to animation classes or DOM ancestry can resolve to the wrong or transient element. When a button should be disabled during submission, assert that transition if it is part of the user experience, then wait for the resulting status.

## Assert outcomes with retrying expectations

Prefer `await expect(status).toHaveText('Submitted')` over reading text once after a delay. [Playwright test assertions documentation](https://playwright.dev/docs/test-assertions) says that web-specific async matchers wait until the expected condition is met, re-fetch the element, and keep checking until the condition passes or the timeout is reached. The assertion expresses the product condition and produces a useful failure showing expected and received state.

Wait for network events only when the network event is the contract. A response finishing does not guarantee that the UI rendered it. For a user-flow test, assert the visible durable outcome. For a transport test, wait for the specific request and inspect its response while triggering the action, avoiding a listener registered too late.

Avoid treating network quiescence as a generic proxy for readiness; prefer waiting for the user-visible condition the test actually depends on. Add an application-visible readiness marker only when it represents real user capability, not a test-only shortcut around unfinished state.

## Remove hidden shared state

If retries pass only because an earlier attempt created missing data, treat that first failure as a setup problem before calling it a timing flake. Give each worker unique identities and records, seed through a supported API or fixture, and clean up by ownership. Do not share one mutable tenant across parallel tests. Freeze or inject time for expiry behavior rather than sleeping until a deadline. Disable animations through user preferences or test configuration only when animation itself is outside the scenario.

Use traces or equivalent failure captures to inspect DOM snapshots, action timing, console errors, requests, and screenshots around the first failure. When you have traces from retries, fix the earliest incorrect state rather than the last timeout. Track flakes by test and failure signature; automatic retries are diagnostic containment, not acceptance criteria.

## Write waits in domain language

Create helpers such as `expectInvoiceReady(id)` only when they assert a meaningful state and expose failure context. Keep their timeout local to the operation's known budget. A report generation test may legitimately wait longer than a menu opening, but it should poll a job state or observe completion, not sleep for the estimated duration.

Test the failure path too: delayed response, server error, duplicate click, navigation during submission, and animation under reduced CPU. Confirm the UI reaches a stable, actionable error or success state.

The next step is to remove the longest fixed sleep in the suite, capture a failing trace, and replace it with one web-first assertion on the condition a user needs. Reviewed 2026-09-06. Re-review after a browser-test framework upgrade.

## Source references

- <https://playwright.dev/docs/actionability>
- <https://playwright.dev/docs/test-assertions>
