Avoiding arbitrary timeouts in async component tests

This page is part of the async behaviour and timers section of the component testing fundamentals guide, which covers waiting for state correctly rather than guessing at how long it takes.

The specific problem: a test contains await new Promise(r => setTimeout(r, 500)) because that was what made it pass. It works on your machine, adds half a second to the suite, and fails on a loaded CI runner anyway.

Problem statement

A fixed sleep encodes a guess about how fast a machine is. The guess is calibrated on the machine where the test was written, and every other machine is a different guess. Too short and the test fails intermittently; too long and the suite pays the difference on every run.

The symptom is a suite with a scattering of magic numbers — 100, 250, 500 — each added at a different time by a different person to fix a different intermittent failure, and none of them documented.

Root cause

The sleep is standing in for a condition. What the test actually wants is “wait until the results list exists”, and it is instead saying “wait 500 ms and hope”. Those are only equivalent when the machine behaves exactly as it did when the number was chosen.

Testing Library’s async utilities express the condition directly. They poll, so a fast machine proceeds immediately and a slow one waits as long as it needs — and when the condition never becomes true, the failure names the condition rather than reporting an elapsed duration.

What each waiting strategy costs and reports Four rows. A fixed 500-millisecond sleep wastes half a second on a fast machine, may still be too short on a slow one, and fails with an unrelated assertion message. A findBy query proceeds in about five milliseconds on a fast machine, waits as long as needed on a slow one, and names the element it could not find. waitFor behaves the same and reports the last error from its callback. Fake timers are instant on both and fail with the assertion's own message. Approach Fast machine Slow machine Failure message sleep(500) wastes 500 ms may still be too short unrelated assertion fails findBy* query proceeds in ~5 ms waits as needed names the missing element waitFor(cb) proceeds in ~5 ms waits as needed shows the last error fake timers instant instant names the assertion

Minimal reproduction

// ❌ the magic number that "fixed" the flake
it('shows results', async () => {
  render(<ResultsPanel query="button" />);
  await new Promise((r) => setTimeout(r, 500));
  expect(screen.getByRole('listitem')).toHaveLength(3);
});

Two things are wrong. The 500 is untethered to anything the component promises, and the assertion that fails when the wait was too short is getByRole, whose message says an element is missing — never that the wait was the problem.

Step-by-step fix

1. Replace the sleep with the condition it stands for

it('shows results', async () => {
  render(<ResultsPanel query="button" />);

  // waits for the list, however long that takes on this machine
  const items = await screen.findAllByRole('listitem');
  expect(items).toHaveLength(3);
});

What this does: polls every 50 ms until at least one list item exists, then asserts the count. On a warm machine the whole test finishes in single-digit milliseconds.

2. Use waitFor when the condition is not an element

Some conditions have no DOM representation — a spy having been called, a store having settled.

await waitFor(() => {
  expect(onLoaded).toHaveBeenCalledWith(expect.objectContaining({ count: 3 }));
});

What this does: re-runs the callback until it stops throwing. The failure message is the callback’s own assertion error, so a genuine bug reads correctly.

3. Wait for disappearance explicitly

await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
expect(screen.getByRole('table')).toBeInTheDocument();

What this does: polls until the spinner is gone. Asserting the table directly would also work, but this version fails with “the element never disappeared”, which points at the loading state rather than at the table.

4. Set the timeout once, globally, and leave it alone

// src/setupTests.ts
import { configure } from '@testing-library/react';

// one place to tune; per-call overrides become a visible exception
configure({ asyncUtilTimeout: 2000 });

What this does: gives every async utility the same ceiling. A test that needs more than the global value is doing something unusual, and the local override documents that.

Verification

The same tests after the sleeps are removed A verbose Vitest run. Three tests that previously relied on fixed sleeps now pass in 12, 9 and 8 milliseconds. The suite duration falls from 2.14 seconds to 0.41 seconds, and a grep confirms no fixed sleeps remain in the source tree. vitest run --reporter=verbose PASS shows results 12 ms PASS clears the spinner 9 ms PASS reports the loaded count 8 ms Duration 0.41 s (was 2.14 s) 0 fixed sleeps remaining in src/

A grep is the honest verification that the pattern is gone:

# any remaining hit is a guess someone will have to re-tune later
grep -rn "setTimeout(r" src --include="*.test.tsx"

Edge cases and caveats

  • A poll interval that exceeds the timeout. A component that refreshes every 30 s cannot be waited for — the condition is real but arrives long after any sensible timeout. Control that clock with fake timers instead of extending the wait.
Suite time before and after the audit Horizontal bars. A suite containing thirty-four fixed sleeps runs in 21.6 seconds; after replacing them with awaited queries it runs in 4.2 seconds. The slowest single test drops from 1.50 seconds to 0.09 seconds. Same 180 tests, same assertions Before: 34 sleeps 21.6 s suite After: awaited queries 4.2 s suite Slowest test before 1.50 s Slowest test after 0.09 s
  • waitFor around a userEvent call. Putting an interaction inside the callback re-fires it on every poll, producing several clicks. Interactions belong outside; only assertions belong inside.

  • Animations that delay the final state. A list that fades in reaches the DOM before it is visible, so a query for the element succeeds while a visibility assertion fails. Disable animations in the test environment rather than waiting for them.

Auditing an existing suite

Removing sleeps from a suite that has accumulated them is a mechanical job, and doing it in one pass is better than doing it opportunistically — a half-converted suite still has the slowest test setting the pace.

Start by listing them. grep -rn "setTimeout(r" src --include="*.test.*" finds the common spelling; jest.setTimeout, vi.setConfig({ testTimeout }) and { timeout: N } overrides are worth listing too, because an inflated timeout is usually hiding the same problem.

Then classify each hit. A sleep before an assertion is standing in for a condition and becomes an awaited query. A sleep after an interaction, with no assertion following, is usually load-bearing for the next test rather than this one, which means state is leaking and the real fix belongs in a teardown hook. A sleep inside a loop is nearly always a poll that should be a waitFor.

Finally, measure. Record the suite duration before and after; the drop is typically the sum of every sleep you removed, which makes the change easy to justify. A test that gets slower has revealed a condition that was never becoming true — that is a real bug the sleep was hiding, and it is worth fixing before moving on.

FAQ

Is a sleep ever legitimate in a component test?

Almost never, and the exceptions are narrow enough to name: asserting that something has not happened after a genuine debounce window, when fake timers are unavailable for some reason. Even there, a fake clock is the better tool. If a sleep survives review, it should carry a comment explaining what condition it is standing in for, so the next person can replace it with that condition.

How long should the global timeout be?

Long enough that a loaded CI runner never hits it on a healthy test, short enough that a genuinely broken test fails quickly. Two seconds suits most component suites. If tests routinely need more, the delay is coming from something that should be mocked — a real request, an uncontrolled retry — and raising the ceiling only postpones dealing with it.

Why did my test get slower after removing the sleeps?

Usually because a waitFor is now waiting for something that never becomes true and burning the full timeout before failing, where the sleep was masking it. That is the correct outcome: the test was passing for the wrong reason and is now telling you so. Read the failure message, which names the condition, and fix the component or the assertion.

What about waiting for a CSS transition to finish?

Do not wait for it — remove it. A test environment that disables animations and transitions globally makes the final state immediate, which is both faster and more deterministic than polling for a visual change. A single stylesheet injected in the setup file, setting transition and animation durations to zero, removes the whole category.