Waiting for Async UI Without Arbitrary Timeouts
Almost every interesting component does something asynchronously: fetches data, debounces input, animates a panel, validates on the server. Tests of those components must wait, and the easiest way to wait — await new Promise(r => setTimeout(r, 500)) — is also the worst. A fixed sleep is either longer than necessary, slowing every run, or shorter than necessary on a busy CI runner, failing at random. Testing Library provides waiting primitives that poll for a condition and return as soon as it holds: findBy queries for elements that will appear, waitForElementToBeRemoved for elements that will disappear, and waitFor for arbitrary assertions. This guide explains what each one actually waits for, the common mistakes that turn them back into disguised sleeps, and when fake timers are the better tool. It sits in Testing Library best practices.
Root Cause Analysis
A fixed sleep encodes a guess about how long something takes. The guess is calibrated on a developer laptop and then run on shared CI machines under load, where the same operation can take several times longer. The result is the classic flaky test: green locally, red one run in twenty in CI, green on retry. Raising the sleep “fixes” it until the next slow day, and every increase makes the suite slower for all the runs where the operation was fast.
Condition-based waiting inverts that. Instead of waiting a fixed time and hoping, the test states what it is waiting for and polls until it is true or a generous timeout passes. The fast case returns immediately and the slow case still succeeds, so the timeout only matters when something is genuinely wrong.
The primitives are easy to misuse, though. waitFor with a side effect inside retries the side effect on every poll. waitFor with an empty callback resolves immediately and waits for nothing. Several assertions inside one waitFor wait for all of them, making failures confusing. And polling cannot help with timers in the component itself: a 300 millisecond debounce is real time that must pass, and fake timers are the right way to control it.
Reproducible Setup
An order-history panel that fetches orders, shows a spinner, and supports a debounced filter box. MSW serves the data; the render helper provides fresh providers per test.
// src/orders/OrderHistory.tsx
export function OrderHistory() {
const [filter, setFilter] = useState('');
const debounced = useDebouncedValue(filter, 300);
const { data, isPending } = useQuery({ queryKey: ['orders', debounced], queryFn: () => fetchOrders(debounced) });
return (
<section aria-label="Order history">
<input aria-label="Filter orders" value={filter} onChange={(e) => setFilter(e.target.value)} />
{isPending ? <p role="progressbar" aria-label="Loading orders" /> :
<ul>{data!.map((o) => <li key={o.id}>{o.ref} — {o.status}</li>)}</ul>}
</section>
);
}
Implementation
Step 1 — Use findBy for elements that will appear. It is waitFor plus getBy, retrying until the element exists or the timeout expires.
test('lists orders once loaded', async () => {
renderWithProviders(<OrderHistory />);
expect(await screen.findByText('A-1001 — shipped')).toBeInTheDocument();
});
Step 2 — Use waitForElementToBeRemoved for elements that will disappear. It fails immediately if the element is not present at the start, which catches tests that wait for a spinner that never appeared.
test('the spinner is removed after loading', async () => {
renderWithProviders(<OrderHistory />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar', { name: 'Loading orders' }));
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
Step 3 — Use waitFor for assertions that are not about elements. Keep a single assertion inside and no side effects.
test('records a page view once data is shown', async () => {
const track = vi.fn();
renderWithProviders(<OrderHistory />, { analytics: { track } });
await waitFor(() => expect(track).toHaveBeenCalledWith('orders_viewed', { count: 3 }));
});
Step 4 — Use fake timers for the component’s own delays. Polling cannot shorten a debounce; advancing a fake clock can.
test('filters after the debounce period', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const { user } = renderWithProviders(<OrderHistory />, { userOptions: { advanceTimers: vi.advanceTimersByTime } });
await screen.findByText('A-1001 — shipped');
await user.type(screen.getByLabelText('Filter orders'), 'returned');
await act(() => vi.advanceTimersByTimeAsync(300));
expect(await screen.findByText('A-0990 — returned')).toBeInTheDocument();
expect(screen.queryByText('A-1001 — shipped')).not.toBeInTheDocument();
vi.useRealTimers();
});
Step 5 — Assert absence after the thing that would add it has settled. Checking that something did not appear right away proves nothing; wait for a positive signal first, then check the absence.
test('does not show cancelled orders by default', async () => {
renderWithProviders(<OrderHistory />);
await screen.findByText('A-1001 — shipped'); // the list has rendered
expect(screen.queryByText(/cancelled/)).not.toBeInTheDocument();
});
Step 6 — Set the timeout once, deliberately. The default is one second. If legitimately slow operations need more, raise asyncUtilTimeout globally in the setup file rather than sprinkling per-call timeouts, and never raise it to hide a flaky test.
// vitest.setup.ts
import { configure } from '@testing-library/react';
configure({ asyncUtilTimeout: 2000 });
Step 7 — Wait for the end of a sequence, not each step. Multi-step flows — submit, show a pending state, then a confirmation — tempt tests into waiting after every step. Usually only the final state matters for the assertion, and intermediate waits add noise. Wait explicitly for an intermediate state only when that state is itself part of the behaviour being tested, such as a disabled submit button while a request is in flight. In that case assert the pending state synchronously right after the interaction, when it is guaranteed to be present, and then wait for the final state. Tests written this way read as a description of what the user experiences, in order, with each wait tied to something the user would notice.
Step 8 — Make the waiting visible in reviews. A short convention helps reviewers spot problems quickly: every await in a component test should be either a user interaction, a findBy query, a waitFor with a single assertion, or a timer advance. Anything else — a bare promise, a helper called flushPromises, a sleep with a comment explaining why it is needed — deserves a second look, because it is usually a guess about timing that will eventually be wrong on some machine. Some teams encode this as a lint rule that forbids setTimeout inside test files entirely, with an explicit, commented exception for the rare legitimate case.
It is worth explaining the reasoning to the team as well as enforcing it. Engineers add sleeps because they work in the moment; showing a flaky run caused by one, and the same test fixed with a findBy query that is both faster and stable, is usually more persuasive than any rule.
Verification
Search the suite for fixed sleeps and replace them one by one, running each changed file repeatedly to confirm stability.
grep -rnE "setTimeout\(r(esolve)?, ?[0-9]+\)|sleep\(" src --include=*.test.tsx
npx vitest run src/orders --reporter=dot --repeat=20
After replacing sleeps, compare suite duration before and after: condition-based waits usually cut total time, because most operations finish far sooner than the sleeps allowed. Then deliberately break a handler so the orders request fails, and confirm the findBy test fails within the timeout with a message showing the DOM — a clear failure rather than a mysterious one.
Troubleshooting
Symptom: findBy times out although the element appears in the browser. Diagnosis: the query does not match — wrong role, name or container — rather than a timing problem. Fix: read the printed DOM and follow diagnosing unable-to-find-element failures.
Symptom: a test with fake timers hangs. Diagnosis: findBy polls with timers that are now fake and never advance. Fix: use shouldAdvanceTime: true, or advance timers explicitly before querying.
Symptom: “The element(s) given to waitForElementToBeRemoved are already removed”. Diagnosis: loading finished before the call, often because data was cached. Fix: assert the spinner is present first, or give each test a fresh cache.
Symptom: act warnings appear after the test finishes. Diagnosis: an async update resolves after the last assertion. Fix: wait for the final state the component reaches; see avoiding act warnings in React Testing Library.
FAQ
Is waitFor slower than a short sleep?
No. It polls every 50 milliseconds by default and returns on the first success, so it is typically faster than any sleep long enough to be reliable.
Can I use waitFor in Playwright tests?
Playwright’s locator assertions already retry, so explicit waiting is rarely needed there; the same principle — wait for a condition, never for time — applies.
Should I wrap user.click in act?
No. User-event already wraps interactions in act. Add act only around code that triggers updates outside Testing Library, such as advancing fake timers.
When is a real delay acceptable?
Almost never in component tests. If a delay is part of the behaviour — a toast that disappears after five seconds — control it with fake timers instead of waiting for it.
What about flushPromises helpers?
They resolve pending microtasks once, which works until the component awaits one more promise. Prefer waiting for the visible result, which keeps working however many promises sit in between.
How do I wait for an animation to finish?
Disable animations in the test environment, or wait for the end state the animation produces — an element being removed or gaining an attribute — rather than its duration.
Related
- Back to Testing Library Best Practices
- Replacing arbitrary waits with deterministic conditions — the same idea across the whole suite.
- Testing loading and error states deterministically — controlling when data arrives.
- Testing debounce and throttle with fake timers — the fake-timer side in depth.