Never waitForTimeout — Wait for the Actual Network
Delete every fixed timeout in your test suite today. A waitForTimeout(5000) is a guess: when the app responds in 300 ms it wastes 4.7 seconds on every single run, and when CI is slow it still fails. Fixed sleeps are the only technique that makes a test suite slower and flakier at the same time — and both problems disappear the moment you wait on the real condition instead of the clock.
Why a fixed sleep can never be right
There is no correct number to put in a sleep. Pick it short and the test flakes on a loaded CI runner; pick it long and you pay the full delay on every green run. Multiply one 5-second sleep by 40 tests and 20 CI runs a day, and you're burning over an hour of machine time daily on waits that verify nothing.
Modern test runners agree. Playwright's own guidance is to rely on web-first assertions: "By using web first assertions Playwright will wait until the expected condition is met." Cypress lists "waiting for arbitrary time periods using cy.wait(Number)" as an explicit anti-pattern and tells you to "use route aliases or assertions to guard Cypress from proceeding until an explicit condition is met."
What to wait on instead
1. The network response. If the UI is waiting on a request, so should the test:
// Playwright — wait for the exact request the click triggers
const responsePromise = page.waitForResponse(
(res) => res.url().includes('/api/users') && res.status() === 200
);
await page.getByTestId('fetch-users').click();
await responsePromise;
// Cypress — alias the route, then wait on the alias
cy.intercept('GET', '/users').as('getUsers');
cy.get('[data-testid="fetch-users"]').click();
cy.wait('@getUsers');
2. The visible result. Web-first assertions re-check the DOM "over and over, until the condition is met or until the timeout is reached" — with a 5-second default timeout in Playwright:
await expect(page.getByTestId('status')).toHaveText('Submitted');
await expect(page.getByRole('row')).toHaveCount(2);
Both resume the instant the condition is true. Fast app, fast test; slow network, patient test. Same code.
Rules of thumb
| You wrote a sleep because… | Replace it with |
|---|---|
| Data is still loading | waitForResponse / aliased cy.wait('@route') |
| Element isn't there yet | expect(locator).toBeVisible() — auto-retries |
| Text hasn't updated | expect(locator).toHaveText(...) |
| Animation is finishing | Assert on the end state, not the transition |
| "It just needs a moment" | You don't know the condition yet — find it first |
Playwright actions also auto-wait on their own: click() runs actionability checks (visible, enabled, stable) before firing, so most pre-click sleeps were never needed at all.
Power-user moves
- Ban sleeps mechanically. Add a lint or grep gate to CI: fail the build on
waitForTimeout(andcy.wait(followed by a number. A rule beats a code-review reminder. - Poll for anything. No locator for your condition? Playwright's
expect.poll(...)andexpect(...).toPass()retry an arbitrary function — a job status endpoint, a DB row — with the same retry semantics as web-first assertions. - Tune timeouts globally, not locally. If CI is genuinely slower, raise the assertion timeout in one config line instead of sprinkling per-test sleeps.
- Tell your AI agent. Coding agents happily copy
waitForTimeoutfrom old tests. One line in your project instructions — "never use fixed timeouts in tests; wait on responses or assertions" — keeps generated tests clean at the source.
Resources
- Playwright — Best practices: use web-first assertions
- Playwright — Auto-retrying assertions
- Playwright —
page.waitForResponse()API - Playwright — Auto-waiting and actionability checks
- Cypress — Best practices: unnecessary waiting
Shipping AI-assisted code? Yeda AI designs, audits, and ships production LLM systems.