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.

What a debounce actually schedules A timeline of a debounced input. Typing the first character schedules a timer. The second character at 120 milliseconds resets it. The third at 240 milliseconds resets it again, highlighted. The debounce finally fires at 540 milliseconds — 300 milliseconds after the last keystroke — issuing one query rather than three. type 'b' 0 ms timer scheduled type 'u' 120 ms timer reset type 't' 240 ms timer reset again debounce fires 540 ms one query, not three The assertion that matters is the count, and it needs both sides of the boundary

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

What the fake clock buys in runtime Horizontal bars comparing runtimes. Twenty debounced-input tests take 6.4 seconds on a real clock, almost all of it waiting, and 0.3 seconds on a fake clock. A single test drops from 0.34 seconds to 0.02 seconds. Same assertions, real versus controlled time Real clock, 20 tests 6.4 s — mostly waiting Fake clock, same 20 0.3 s Real clock, one test 0.34 s Fake clock, one test 0.02 s
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.
Five deferred behaviours and how to advance them Five rows. A trailing debounce fires once after a quiet period; assert the call count and the final value after advancing the full delay. A leading debounce fires immediately; assert the value from the first event without advancing. A throttle fires at a fixed rate; assert the count per window, advancing one interval at a time. A poll fires forever on an interval; assert the count after a chosen number of intervals. A retry backoff fires at growing gaps; assert the count at each boundary, advancing each gap in turn. Behaviour Fires Assert Advance by Debounce (trailing) once, after quiet count and final value the full delay Debounce (leading) once, immediately value on first event nothing Throttle at a fixed rate count per window one interval at a time Poll forever, on interval count after N intervals N × interval Retry backoff at growing gaps count at each boundary each gap in turn
  • vi.runAllTimers with a self-rescheduling timer. A poll that schedules its own next run will loop forever under runAllTimers. Use advanceTimersByTime with an explicit duration, or runOnlyPendingTimers, which executes what is queued now and stops.

  • Date-dependent rendering. Fake timers freeze Date.now as well, which is usually welcome. If a component renders a relative timestamp, pass an explicit now to useFakeTimers so 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.