Async behaviour and fake timers in component tests

This section sits under component testing fundamentals and covers the one class of failure that survives every other isolation measure: time. A component whose render depends on a debounce, a poll, an animation frame, or an unresolved promise can be perfectly isolated from the network and the store and still fail intermittently, because the assertion and the update are racing.

Prerequisites

Why timing failures look like flakiness

A component test asserts on the DOM. The DOM is updated by the framework, and the framework decides when to apply an update — React batches state changes and flushes them in a microtask; Vue schedules on the next tick; a debounced handler defers by a timer that has not fired yet. The assertion, meanwhile, runs synchronously on the line after the interaction.

Whether the test passes therefore depends on whether the update happened to land before the assertion read the DOM. Locally, on a warm machine with a fast event loop, it usually does. In CI, on a shared runner processing microtasks at a different cadence, it often does not. Nothing about the component changed — only the timing did, which is why these failures are so consistently misdiagnosed as “flaky infrastructure”.

There are exactly two correct responses. Either wait for the state you expect, using a query that re-polls until it appears, or control the clock, so the timer that defers the update fires under your instruction rather than in real time. A fixed setTimeout in the test is neither: it is a guess about how long the machine will take, and it is wrong on the machine you did not test on.

Where a synchronous assertion lands A timeline of one interaction. At zero the click is dispatched. Almost immediately a synchronous assertion runs, highlighted, and reads a DOM that has not been updated. Four milliseconds later the framework flushes the state change. Three hundred milliseconds later a debounced handler fires a second update, and only then does the component reach the state the test intended to assert. interact t = 0 click dispatched sync assert t + 0 ms reads stale DOM framework flush t + 4 ms state committed debounce fires t + 300 ms second update final state t + 305 ms what you meant to assert A fixed sleep guesses at this axis; a re-polling query measures it

Step-by-step implementation

Step 1 — Replace synchronous queries with awaited ones

Any assertion about state that appears after an interaction must use a query that waits. Testing Library’s findBy* family returns a promise that re-polls the DOM until a match appears or the timeout expires.

// SearchBox.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from './SearchBox';

it('shows results after typing', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'button');

  // findByRole re-polls until the list exists — it does not guess a duration
  expect(await screen.findByRole('list', { name: /results/i })).toBeInTheDocument();
});

Verify it works: temporarily change the expected name to something the component never renders. The failure message should read Unable to find role="list" after the full timeout, not immediately — that confirms the query really is polling.

Step 2 — Install fake timers for anything that defers

A debounce, a poll, or a retry backoff should not cost the suite real seconds. Fake timers let you advance the clock explicitly, which is both faster and deterministic.

import { vi } from 'vitest';

beforeEach(() => {
  // shouldAdvanceTime keeps microtasks flowing so awaited promises still resolve
  vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
  vi.useRealTimers();
});

Verify it works: a test that previously took 300 ms of real time for a debounce should now complete in single-digit milliseconds, and the suite total should drop by roughly the sum of every deferred duration.

Step 3 — Teach userEvent about the fake clock

userEvent inserts small delays between the events that make up a realistic interaction. With a fake clock installed those delays never elapse, and the interaction hangs. The setup call has to be told how to advance time.

const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

await user.type(screen.getByRole('searchbox'), 'but');
await vi.advanceTimersByTimeAsync(300);   // fire the debounce deliberately
expect(await screen.findByRole('list')).toBeInTheDocument();

Verify it works: remove the advanceTimers option and the same test should time out inside user.type. That failure is the signature of a fake clock and a real userEvent in the same test.

Step 4 — Flush pending work before asserting an absence

Asserting that something did not happen is the one case a re-polling query cannot help with: queryBy* returns null immediately, whether the update is absent or merely late. Flush first, then assert.

await user.click(screen.getByRole('button', { name: /save/i }));
await vi.runOnlyPendingTimersAsync();   // let every scheduled callback run
await Promise.resolve();                // and drain the microtask queue

expect(screen.queryByRole('alert')).not.toBeInTheDocument();

Verify it works: introduce a deliberate bug that renders the alert after a 50 ms timer. Without the flush the test passes (wrongly); with it, the test fails.

Step 5 — Fail the suite on unhandled rejections

A promise that rejects after the test has finished is reported against whichever test is running at the time, which sends every investigation to the wrong file.

// vitest.config.ts
export default defineConfig({
  test: {
    dangerouslyIgnoreUnhandledErrors: false,   // the default; never turn this on
    setupFiles: ['./src/setupTests.ts'],
    restoreMocks: true,
  },
});

Verify it works: vitest run should report an unhandled rejection as its own failure with the originating stack, rather than folding it into an unrelated test.

Configuration reference

The seven controls that make timing deterministic Seven rows. findBy queries re-poll until a match appears and suit post-interaction assertions. waitFor re-polls an arbitrary condition when there is no single element to query. waitForElementToBeRemoved polls until a node disappears, for asserting a spinner clears. useFakeTimers takes control of the clock for any deferred behaviour. The shouldAdvanceTime option keeps microtasks flowing and should always be set with fake timers. Passing advanceTimers to userEvent.setup lets it drive the fake clock. runOnlyPendingTimersAsync flushes scheduled callbacks before asserting that something is absent. Tool / option Purpose Set it when findBy* queries re-poll until a match appears asserting post-interaction state waitFor(cb) re-poll an arbitrary condition no single element to query waitForElementToBeRemoved poll until a node disappears asserting a spinner clears vi.useFakeTimers take control of the clock any deferred behaviour shouldAdvanceTime keep microtasks flowing always, with fake timers advanceTimers on setup() let userEvent drive the clock fake timers + userEvent runOnlyPendingTimersAsync flush what is scheduled now before asserting absence
Option Type Default Effect
timeout (findBy/waitFor) number (ms) 1000 How long a query re-polls before failing
interval (waitFor) number (ms) 50 Gap between poll attempts
shouldAdvanceTime boolean false Lets real microtasks progress under a fake clock
advanceTimers function none Function userEvent calls to move the fake clock
asyncUtilTimeout number (ms) 1000 Global default for every Testing Library async utility

Common pitfalls

Wrapping a synchronous assertion in waitFor. waitFor(() => expect(x).toBe(1)) on a value that never changes asynchronously succeeds on the first poll and hides nothing — but it also swallows the real failure message behind a timeout, making a genuine bug much harder to read. Use waitFor only for conditions that genuinely become true later.

Raising the timeout instead of finding the wait. A findBy* that needs five seconds is nearly always waiting on something that should have been mocked. Raising the timeout converts a fast failure into a slow one and leaves the real dependency in place.

Installing fake timers globally without restoring them. A fake clock that leaks into the next file makes every subsequent timer-dependent test behave oddly, and the failure appears far from its cause. Pair useFakeTimers with useRealTimers in afterEach, every time.

Using act() manually to silence a warning. The warning means an update happened outside the code the test was awaiting. Wrapping the assertion in act removes the message without removing the race — the correct fix is almost always an awaited query.

Guessing at a duration versus measuring it Top lane: a click is followed by a fixed hundred-millisecond sleep, an arbitrary number tuned on a fast laptop, and then an assertion that passes locally and fails in CI. Bottom lane: the same click is followed by a findBy query that polls every fifty milliseconds until the state appears, and the assertion passes in both environments. Guessing — a fixed sleep, tuned on a fast laptop click sleep 100 ms arbitrary assert passes locally, fails in CI Measuring — a re-polling query click findBy* polls every 50 ms assert passes on both

Choosing between waiting and controlling

Both techniques make a test deterministic, and they solve different halves of the problem. Waiting adapts to however long the environment takes; controlling removes the duration entirely. Knowing which to reach for saves a lot of guesswork.

Wait when the delay is not yours. A framework flush, a resolved fetch promise, a transition the browser schedules — you do not know how long these take and you should not care. An awaited query re-polls until the state arrives, which is correct on a fast laptop and on a loaded CI runner alike.

Control when the delay is a deliberate product decision. A 300 ms debounce, a 30 s poll interval, an exponential retry backoff — these durations exist because someone chose them, and the test’s job is to prove the behaviour at the boundary, not to sit through the wait. Fake timers let you assert the state before the timer fires, advance to exactly the boundary, and assert again.

Use both when the behaviour has both. A debounced search that then fetches has a controlled delay followed by an uncontrolled one. Advance the clock past the debounce, then await the result query. Trying to control the fetch as well — by advancing until the promise resolves — couples the test to the mock’s internals and breaks the moment the mock changes.

The one combination to avoid is a real clock with a fixed sleep, because it is neither: it neither adapts nor controls, and its correctness depends entirely on hardware you have not tested on.

Cleaning up work the component started

A component that schedules work is responsible for cancelling it when it unmounts, and a test is the cheapest place to prove that. This matters beyond tidiness: a timer or subscription that outlives its component writes to state that no longer exists, which in a real application is a memory leak and in a test suite is a mysterious failure in whichever file runs next.

it('cancels its poll on unmount', async () => {
  vi.useFakeTimers({ shouldAdvanceTime: true });
  const onTick = vi.fn();
  const { unmount } = render(<StatusPoller intervalMs={5000} onTick={onTick} />);

  await vi.advanceTimersByTimeAsync(5000);
  expect(onTick).toHaveBeenCalledTimes(1);

  unmount();
  await vi.advanceTimersByTimeAsync(15000);   // three more intervals
  expect(onTick).toHaveBeenCalledTimes(1);    // still one — the poll was cleared
});

Verify it works: delete the clearInterval from the component’s cleanup function. The second assertion should fail with four calls instead of one, which is exactly the leak the test exists to catch.

The same pattern covers event listeners, AbortController signals, and subscriptions: perform the interaction, unmount, advance or emit again, and assert nothing further happened. vi.getTimerCount() is a useful blunt instrument here — asserting it is zero after unmount catches any scheduled work you forgot to name.

Integration point

Async discipline is what makes every other tier trustworthy. The isolation principles remove ambient state, but a component that updates on a timer still needs the clock controlled or the isolation buys nothing. In Storybook, the same rules apply inside a play function — interaction testing fails in CI for exactly this reason more often than for any other. And in visual regression, an animation still in flight is a timing bug that surfaces as a pixel diff rather than as a failed assertion, which is why dynamic content masking starts by freezing time.

FAQ

Why does my test pass locally and time out in CI?

Because the assertion and the update are racing, and the two machines resolve the race differently. A CI runner is usually slower and shares its CPU, so a framework flush or a network-mocked promise that resolves in two milliseconds locally may take twenty there. A synchronous query samples the DOM once and loses; an awaited findBy* re-polls and wins on both machines. Raising the timeout is not the fix — it only widens the window in which the same race can still be lost.

Do I need fake timers if I already use findBy*?

Not for correctness, but for speed. An awaited query will eventually see the state a real 300 ms debounce produces, so the test passes either way; it just spends 300 ms of real time doing so. Multiply that across a suite with dozens of debounced inputs and polls and the cost becomes minutes. Fake timers make the same test deterministic and effectively instant.

What does the act() warning actually mean?

That the component updated its state outside the code the test framework was tracking — typically because a promise resolved or a timer fired after the awaited interaction had already returned. The warning is telling you there is an unawaited update, not that you need to add an act call. Find the thing that resolves late — usually an unmocked request or an uncontrolled timer — and await it properly.

Should I use waitFor or findBy*?

Prefer findBy* whenever you are waiting for an element, because the failure message names the query and prints the DOM, which makes a real failure readable. Reach for waitFor when the condition is not “an element exists” — a mock having been called, a store having reached a value, several conditions being true at once.

Are fake timers safe to enable for the whole suite?

Enabling them globally is workable, but only with shouldAdvanceTime: true and a matching useRealTimers in afterEach. Without the first, any test that awaits a real promise chain stalls; without the second, a leaked fake clock makes unrelated later tests behave strangely. The lower-risk default is to install them per file, in the files that actually have deferred behaviour, so the blast radius of a mistake is one file rather than the run.

How do I test an exponential backoff without waiting for it?

Advance the clock to each expected boundary and assert the attempt count in between. With a base of 200 ms, assert one attempt, advance 200 ms and assert two, advance 400 ms and assert three, advance 800 ms and assert four. That proves the schedule rather than the total, which is what the backoff actually specifies — and it runs in microseconds instead of the several real seconds the sequence would otherwise take.

Why does waitFor sometimes pass when the component is broken?

Because waitFor resolves as soon as its callback stops throwing, and an assertion that was already true when the first poll ran resolves immediately. If the callback asserts something that is true before the interaction as well as after, it never had a chance to fail. Assert on the state that specifically distinguishes “after” from “before” — the new text, the removed spinner, the changed count — rather than on something incidentally true in both.