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.

Fixed sleeps versus condition-based waiting A fixed sleep waits the same time whether the operation is fast or slow, wasting time when fast and failing when slow. Condition-based waiting polls and returns as soon as the condition is met, so it is fast when possible and tolerant when the machine is slow. fixed sleep always waits 500 ms wasted time when fast fails when CI is slow wait for a condition polls until true returns as soon as it holds timeout only on real failure
The timeout becomes a failure threshold rather than a delay every run must pay.

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 }));
});
Which waiting primitive to use findBy queries wait for an element to appear. waitForElementToBeRemoved waits for an element that is present to disappear. waitFor retries any single assertion, such as a mock being called. Fake timers handle delays the component itself imposes. findBy element will appear …ToBeRemoved present element will disappear waitFor one assertion, no side effects fake timers delays inside the component
Pick the most specific primitive; it produces the clearest failure message when the wait times out.

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.

Common misuses of waitFor Clicking or dispatching inside waitFor repeats the action on every poll. An empty callback waits for nothing. Many assertions in one callback give confusing failures. The fixes are to act outside, assert inside, and keep one assertion per wait. misuse side effects inside empty or many assertions fix act outside, assert inside one assertion per wait
waitFor is a retrying assertion, not a place to run code.

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.