Replacing Arbitrary Waits With Deterministic Conditions

A waitForTimeout(2000) is a bet that two seconds is always enough, placed against a machine whose speed you do not control. It is wrong in both directions at once: too long on a fast machine, where it wastes two seconds of every run, and too short on a loaded CI runner, where it produces the intermittent failure it was added to prevent. Every arbitrary wait in a suite can be replaced with a condition that describes what the test is actually waiting for, and doing so makes the suite both faster and more reliable at the same time — a rare combination. This guide covers the replacements for each common case, and it sits under flaky test mitigation.

Root Cause Analysis

A sleep encodes a guess about duration where the test actually cares about a state. The developer who wrote it observed that the assertion ran too early, reached for the smallest fix, and moved on — which is entirely reasonable in the moment and corrosive in aggregate. The guess embeds the timing of the machine it was written on into the test forever.

The second cost is cumulative and usually larger than the flakiness. Fifty sleeps averaging a second each add fifty seconds to every run, on every machine, forever — including the ninety-nine per cent of runs where the condition was satisfied in twenty milliseconds. A suite with a hundred sleeps spends more time asleep than testing.

The third problem is that a sleep hides the failure it was masking. If a test needs to wait for a request, waiting for the response to be rendered also asserts that the response arrived and was handled. A sleep asserts nothing, so when the request silently fails, the test proceeds and produces a confusing downstream failure rather than an immediate, informative one.

A fixed sleep against a variable condition The condition is usually satisfied in a fraction of the sleep, so a fixed wait spends the remainder idle on every run, and on a slow run the same fixed wait ends before the condition is met and the test fails. Typical run ready idle — waiting out the remaining sleep 2000ms spent, 90ms needed Slow run sleep expires here ready test fails a condition ends at the orange mark in both cases — fast when fast, patient when slow
The fixed wait is simultaneously too long and too short; a condition is neither.

Reproducible Setup

Find every arbitrary wait first — the list is usually longer than anyone expects.

grep -rnE "waitForTimeout|setTimeout\(resolve|sleep\(|delay\(" \
  --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" . | tee waits.txt | wc -l
# 63
// the helper that makes them easy to write, and therefore easy to accumulate
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Deleting that helper, once the call sites are gone, is what stops the count growing back.

Implementation

Step 1 — In Playwright, use web-first assertions. These retry until the condition holds or the timeout expires, so they are fast on a fast machine and patient on a slow one.

// before
await page.click('#save');
await page.waitForTimeout(2000);
expect(await page.textContent('.status')).toBe('Saved');

// after
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');

Step 2 — Wait for the specific network or navigation event when the change is not visible. Some state changes have no visual signal; wait for the thing that actually happened rather than for time to pass.

// wait for the response the click triggers, not for two seconds
const saved = page.waitForResponse(
  (r) => r.url().includes('/api/orders') && r.request().method() === 'POST' && r.ok(),
);
await page.getByRole('button', { name: 'Save' }).click();
await saved;

Step 3 — In Testing Library, use the async queries. findBy polls until the element appears; waitFor covers conditions that are not about an element.

// before
render(<Orders />);
await sleep(500);
expect(screen.getByText('3 orders')).toBeInTheDocument();

// after
render(<Orders />);
expect(await screen.findByText('3 orders')).toBeInTheDocument();

// for a non-element condition
await waitFor(() => expect(mockAnalytics).toHaveBeenCalledWith('orders_viewed'));

Step 4 — Use fake timers where the delay is the behaviour. When testing a debounce or a poll, you do not want to wait at all — you want to control the clock.

import { vi, test, expect } from 'vitest';

test('debounces the search request by 300ms', async () => {
  vi.useFakeTimers();
  const onSearch = vi.fn();
  const search = debounce(onSearch, 300);

  search('sh'); search('sho'); search('shoes');
  expect(onSearch).not.toHaveBeenCalled();

  await vi.advanceTimersByTimeAsync(300);
  expect(onSearch).toHaveBeenCalledExactlyOnceWith('shoes');
  vi.useRealTimers();
});
Replacement for each kind of wait Waiting for visible state maps to a web-first assertion or a findBy query, waiting for a request maps to waitForResponse, waiting on a delay in the code maps to fake timers, and waiting for a service to start maps to an explicit readiness probe. What you are really waiting for Use this instead a change the user would see expect(...).toHaveText / findBy a request to complete waitForResponse a delay the code itself imposes fake timers a service to become ready poll a health endpoint
Every sleep is one of these four in disguise; naming which one tells you the replacement.

Step 5 — Replace startup sleeps with readiness probes. A sleep before the suite starts is the same mistake at a larger scale, and the fix is the same: poll for the condition.

// playwright.config.ts — the runner waits for the URL to respond, no sleep needed
export default defineConfig({
  webServer: {
    command: 'npm run start:test',
    url: 'http://localhost:3000/api/health',
    timeout: 60_000,
    reuseExistingServer: !process.env.CI,
  },
});

Step 6 — Ban the pattern once it is gone. A lint rule keeps the count at zero without anyone policing reviews.

// eslint.config.js
export default [{
  files: ['**/*.{test,spec}.{ts,tsx}'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: "CallExpression[callee.property.name='waitForTimeout']",
      message: 'Wait for a condition (toHaveText, findBy, waitForResponse) rather than a duration.',
    }],
  },
}];

A note on doing this to an existing suite: convert file by file rather than in one sweeping change. Each conversion is a small judgement about what the test is actually waiting for, and a hundred of those judgements in one pull request cannot be reviewed meaningfully. Converting a file at a time also lets you measure the saving as you go, which keeps the work visibly worthwhile rather than a chore somebody volunteered for.

Verification

Verify the count is going down and stays down, which is the simplest possible measure of progress.

grep -rcE "waitForTimeout|sleep\(" --include="*.test.ts" --include="*.spec.ts" . | awk -F: '{s+=$2} END {print s}'
# 0

Then verify the suite got faster, because that is the immediate payoff and it makes the case for finishing the job.

npx playwright test --reporter=line
# before: 24 passed (3m41s)
# after:  24 passed (1m12s)

Finally, verify that the replacements genuinely assert something. A waitFor with an empty body, or a condition that is true before the action, is a sleep with extra steps. Read each replacement and confirm it would fail if the behaviour were broken.

// still useless: the element exists before the click too
await waitFor(() => expect(screen.getByRole('button')).toBeInTheDocument());

// meaningful: only true after the action succeeded
expect(await screen.findByRole('status')).toHaveTextContent('Order saved');
Two benefits from one change Replacing fixed waits with conditions removes idle time on every run and removes the failure mode where a slow machine outlasts the fixed wait, so the suite becomes both faster and more reliable. faster no idle time when the condition is met early typically the common case more reliable patient when the machine is slow or contended no fixed budget to exceed
Speed and reliability usually trade against each other; here they move together.

Troubleshooting

Symptom: the condition-based version times out where the sleep passed. Diagnosis: the condition is not the one the test was waiting for — often it waits for an element that is replaced rather than updated, so the original node never changes. Fix: re-query rather than holding a reference, which is what the retrying assertions do by design; a stale handle is the usual cause.

Symptom: an assertion passes immediately and then the test fails later. Diagnosis: the condition was already true before the action, so the wait did nothing. Fix: assert on a state that can only be reached after the action — a status message, a disabled button, a row count — rather than on the mere presence of something.

Symptom: fake timers break an unrelated part of the test. Diagnosis: they replace every timer including those inside libraries, so a component that polls or animates behaves differently. Fix: restrict which timers are faked, and always restore real timers in teardown; the details are covered in controlling Date.now and setTimeout in Jest.

Symptom: a test still needs a small sleep and nothing else works. Diagnosis: usually an animation or a transition with no completion signal exposed. Fix: disable animations in the test environment rather than waiting them out — it is faster, deterministic, and the approach used for stable screenshots in reducing flaky screenshots with deterministic rendering.

FAQ

Is a sleep ever legitimate?

Very rarely, and the honest ones are about the environment rather than the application — waiting for a container’s port to open when no health endpoint exists, for instance. Even then a polling loop with a timeout is better, because it ends as soon as the condition holds. If a sleep survives review, it should carry a comment explaining what has no signal and why.

What timeout should the condition use?

Generous, because the timeout is a failure budget rather than a duration: a condition that is met in fifty milliseconds costs fifty milliseconds regardless of whether the timeout is five seconds or thirty. Set it high enough that a slow CI machine never trips it, and rely on the condition for speed.

Do retrying assertions hide real slowness?

They can, which is why the duration trend matters alongside them. A test that used to satisfy its condition in fifty milliseconds and now takes four seconds still passes, and only a timing trend will tell you. That is one of the numbers worth keeping in tracking test duration trends over time.

How do I handle something that genuinely takes a long time?

Wait for its completion signal with a long timeout, and consider whether it belongs in a test at all. A thirty-second export job is better tested by asserting that it was enqueued, with the job itself covered separately — a browser test that waits half a minute is expensive in every run, forever.