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.
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();
});
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');
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.
Related
- Back to Flaky Test Mitigation
- Retrying flaky Playwright tests without masking bugs — the containment when a fix is not immediate.
- Waiting for async UI without arbitrary timeouts — the component-tier version of this work.
- Detecting flaky tests by repeating runs in CI — finding the tests that need it.