Testing debounced inputs with fake timers
This page is part of the async behaviour and timers section of the component testing fundamentals guide, which covers taking control of the clock so deferred behaviour is testable.
The specific problem: a search input debounces by 300 ms before it queries. Your test types into it and asserts on the results, and the suite now spends 300 ms of real time per test doing nothing.
Problem statement
Debouncing is a product decision — the delay exists so the user is not queried on every keystroke — and a test has to prove two separate things about it: that the query does not fire before the delay, and that it fires exactly once after. A test using real time can only prove the second, slowly, and proves it by waiting rather than by asserting.
The symptom is a suite whose runtime is dominated by deliberate delays. Twenty debounced-input tests at 300 ms each is six seconds of pure waiting, and the number grows every time someone adds a poll or a retry.
Root cause
setTimeout schedules against the real clock, and the test has no way to ask for that clock to move. Waiting is the only option available, so the test’s duration is bound to the product’s chosen delay. Worse, the interesting assertion — that nothing happened before the delay elapsed — cannot be made reliably at all, because there is no defined moment at which “before” ends.
Fake timers replace the scheduling functions with instrumented versions that only advance when told. The delay becomes a value the test can step through, which turns both halves of the behaviour into ordinary assertions.
Minimal reproduction
// SearchInput.tsx
export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [value, setValue] = useState('');
useEffect(() => {
if (!value) return;
const t = setTimeout(() => onSearch(value), 300);
return () => clearTimeout(t);
}, [value, onSearch]);
return <input aria-label="Search" value={value} onChange={(e) => setValue(e.target.value)} />;
}
// ❌ real time: slow, and cannot assert the "not yet" half
it('searches', async () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
await userEvent.type(screen.getByLabelText('Search'), 'but');
await new Promise((r) => setTimeout(r, 400)); // a guess
expect(onSearch).toHaveBeenCalledTimes(1);
});
Step-by-step fix
1. Install a fake clock around the test
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers(); // never let a fake clock outlive its test
});
What this does: setTimeout and friends are replaced with instrumented versions. shouldAdvanceTime keeps microtasks flowing, so awaited promises still resolve normally.
2. Wire userEvent to the fake clock
userEvent waits between the events that make up a keystroke. Under a frozen clock those waits never end, so the typing call hangs.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
What this does: gives userEvent a function it can call to move the clock, so its internal delays elapse instantly and deterministically.
3. Assert both sides of the boundary
This is the part real time cannot do. Advance to just before the delay and assert nothing fired; advance past it and assert exactly one call.
it('debounces to a single search', async () => {
const onSearch = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<SearchInput onSearch={onSearch} />);
await user.type(screen.getByLabelText('Search'), 'but');
await vi.advanceTimersByTimeAsync(299);
expect(onSearch).not.toHaveBeenCalled(); // still inside the window
await vi.advanceTimersByTimeAsync(1);
expect(onSearch).toHaveBeenCalledTimes(1); // fired once, not three times
expect(onSearch).toHaveBeenCalledWith('but');
});
What this does: proves the debounce’s actual contract — one call, with the final value, no earlier than the configured delay.
4. Prove the timer is cancelled on unmount
A debounce that survives unmount calls a handler on a component that no longer exists.
const { unmount } = render(<SearchInput onSearch={onSearch} />);
await user.type(screen.getByLabelText('Search'), 'but');
unmount();
await vi.advanceTimersByTimeAsync(1000);
expect(onSearch).not.toHaveBeenCalled();
What this does: exercises the effect’s cleanup path, which is the code most likely to be missing and least likely to be noticed.
Verification
vitest run src/SearchInput.test.tsx --reporter=verbose
# ✓ debounces to a single search 18 ms
# ✓ cancels the pending search on unmount 6 ms
The runtimes are the confirmation: any test still taking roughly the debounce duration is running on the real clock, which usually means useFakeTimers was called after render.
Edge cases and caveats
- Throttle is not debounce. A throttled handler fires on a leading or trailing edge at a fixed rate, so the assertion is a call count over a window rather than a single call after a delay. Advance in increments of the throttle interval and assert the count grows by one each time.
-
vi.runAllTimerswith a self-rescheduling timer. A poll that schedules its own next run will loop forever underrunAllTimers. UseadvanceTimersByTimewith an explicit duration, orrunOnlyPendingTimers, which executes what is queued now and stops. -
Date-dependent rendering. Fake timers freeze
Date.nowas well, which is usually welcome. If a component renders a relative timestamp, pass an explicitnowtouseFakeTimersso the rendered text is stable rather than dependent on when the suite ran.
Testing the value, not only the count
A debounce test that only counts calls passes when the component sends the wrong string. Two assertions close that gap.
Assert the argument, not just the invocation. toHaveBeenCalledWith('but') proves the handler received the value at the moment the timer fired, which is the whole point of trailing-edge debouncing — an implementation that captured the value on the first keystroke would still be called once, with 'b'.
Assert what happens when input changes mid-flight. Type, advance part-way, type again, then advance past the delay: the correct behaviour is one call with the final value. This is the case that catches an effect missing its cleanup, because a stale timer produces two calls where there should be one.
await user.type(input, 'but');
await vi.advanceTimersByTimeAsync(200); // still inside the window
await user.type(input, 'ton'); // resets it
await vi.advanceTimersByTimeAsync(300);
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toHaveBeenCalledWith('button');
Together these two assertions describe the debounce completely: one call, at the right time, with the right value.
FAQ
Should I read the debounce delay from the component or hard-code it in the test?
Import the constant rather than repeating the number. A test that hard-codes 300 keeps passing when the product changes the delay to 500 — it simply advances further than necessary — so the change ships untested. Importing the same constant the component uses makes the test fail if the delay moves, which is the prompt to decide whether the new value is intended.
Why does advanceTimersByTime not trigger my await-ed assertion?
Because the synchronous variant moves the clock without yielding to the microtask queue, so promises scheduled by the timer callback have not resolved by the time your next line runs. Use advanceTimersByTimeAsync, which advances and then drains microtasks — that is nearly always what a test with an awaited assertion wants.
Can I use fake timers with React Query or SWR?
Yes, and it is the only practical way to test their retry and refetch intervals. Both libraries schedule with setTimeout, so a fake clock lets you advance to each retry boundary and assert the attempt count. Set the client’s retry delay explicitly in the test rather than relying on the default, so the boundaries you advance to are the ones you chose.
Related
- Async Behaviour and Timers — the parent section covering awaited queries, fake timers and cleanup
- Isolation Principles — removing the ambient state that makes timing bugs order-dependent
- Mock Boundaries — intercepting the network so promises resolve predictably
- Managing Component State During Automated Tests — the reset-and-flush sequence a deterministic test follows