Testing Debounce and Throttle With Fake Timers

Debounce and throttle are small functions with precise contracts, and the contracts are easy to get subtly wrong. A search box that debounces by 300 milliseconds should send one request after the user stops typing — not one per keystroke, and not one that fires 300 milliseconds after the first keystroke. A scroll handler throttled to once every 100 milliseconds should run on the leading edge, at most once per window, and once more at the end so the final position is not lost. Tested with real timers, these behaviours are either skipped or asserted with sleeps that make the suite slow and flaky. This guide covers testing both with Vitest’s fake timers: advancing time precisely, checking leading and trailing edges, maximum wait, cancellation on unmount, and debounced inputs in React components. It sits under time and date control strategies.

Root Cause Analysis

The two patterns are easily confused, and the confusion produces real bugs. Debounce waits for a pause: every call resets the timer, and the function runs once the calls stop for the configured interval. Throttle limits a rate: the function runs at most once per interval however many calls arrive. Swap them and a search box fires on every keystroke up to the rate limit, or a scroll handler never fires while the user is scrolling.

Within each, the edge options change behaviour significantly. A trailing debounce runs after the pause; a leading debounce runs immediately and then ignores calls until the pause. A throttle without a trailing call drops the final event, so a resize handler may never see the window’s final size. These are configuration choices that library defaults make for you, and the defaults differ between libraries.

Real-timer tests cannot pin any of this precisely. Sleeping for 350 milliseconds after a 300 millisecond debounce passes on a fast machine and fails on a loaded CI runner, and it cannot distinguish “ran at 300” from “ran at 1”. Fake timers make time an input: the test decides exactly when each call happens and exactly how far the clock advances between them.

Debounce versus throttle for the same burst of calls A burst of calls over 250 milliseconds produces one debounced call 300 milliseconds after the last input, while a 100 millisecond throttle produces a leading call and then at most one call per window including a trailing call at the end. calls debounce 300 once, after the pause throttle 100 leading, per window, trailing 0 ms 250 ms 550 ms
The same input produces very different output — which is why each pattern's exact contract deserves a test.

Reproducible Setup

Enable fake timers per test and always restore them, so no test inherits another’s clock.

// test/timers.ts
import { beforeEach, afterEach, vi } from 'vitest';

export function useFakeClock() {
  beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }));
  afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); });
}
// src/lib/debounce.ts — the function under test
export function debounce<A extends unknown[]>(fn: (...a: A) => void, wait: number, { maxWait }: { maxWait?: number } = {}) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let firstCall: number | undefined;
  const debounced = (...args: A) => {
    const now = Date.now();
    firstCall ??= now;
    clearTimeout(timer);
    const remaining = maxWait !== undefined ? Math.min(wait, maxWait - (now - firstCall)) : wait;
    timer = setTimeout(() => { firstCall = undefined; fn(...args); }, Math.max(0, remaining));
  };
  debounced.cancel = () => { clearTimeout(timer); firstCall = undefined; };
  return debounced;
}

Implementation

Step 1 — Pin the trailing edge exactly. Call repeatedly, advance to one millisecond before the deadline and assert nothing happened, then advance one more and assert exactly one call with the latest arguments.

// src/lib/debounce.test.ts
import { test, expect, vi } from 'vitest';
import { useFakeClock } from '../../test/timers';
import { debounce } from './debounce';

useFakeClock();

test('fires once, 300ms after the last call, with the last arguments', () => {
  const fn = vi.fn();
  const search = debounce(fn, 300);

  search('s'); vi.advanceTimersByTime(100);
  search('sh'); vi.advanceTimersByTime(100);
  search('sho');

  vi.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledExactlyOnceWith('sho');
});

The bracketing pattern — one assertion at the deadline minus one millisecond, one at the deadline — is worth using for every timing contract in the codebase. It costs one extra line and converts a vague “after roughly this long” into an exact statement, so an implementation that fires a few milliseconds early or late fails immediately rather than drifting unnoticed until a real user encounters it.

Step 2 — Test the maximum wait. Without it, a user who types continuously never triggers a search; with it, the function runs at least once per maxWait however steady the input.

test('fires by maxWait even while calls keep arriving', () => {
  const fn = vi.fn();
  const search = debounce(fn, 300, { maxWait: 1000 });

  for (let i = 0; i < 12; i++) { search(`q${i}`); vi.advanceTimersByTime(100); }

  expect(fn).toHaveBeenCalledOnce();                       // at ~1000ms, not at the end
  expect(fn).toHaveBeenCalledWith(expect.stringMatching(/^q9$/));
});

Step 3 — Test the throttle’s leading and trailing calls. The trailing call is the one most often missing, and the one that loses the final value.

import { throttle } from './throttle';

test('runs on the leading edge, at most once per window, and once at the end', () => {
  const fn = vi.fn();
  const onScroll = throttle(fn, 100, { leading: true, trailing: true });

  onScroll(0);                       // leading: runs now
  vi.advanceTimersByTime(30); onScroll(30);
  vi.advanceTimersByTime(30); onScroll(60);
  vi.advanceTimersByTime(40);        // window ends: trailing call with the latest value

  expect(fn.mock.calls).toEqual([[0], [60]]);
});

Step 4 — Test cancellation. A debounced save that fires after the component has unmounted writes stale data or updates unmounted state; cancel must prevent it.

test('cancel prevents a pending call', () => {
  const fn = vi.fn();
  const save = debounce(fn, 500);
  save({ draft: 'x' });
  save.cancel();
  vi.advanceTimersByTime(1000);
  expect(fn).not.toHaveBeenCalled();
});
Pinning a deadline to the millisecond Advancing to one millisecond before the deadline and asserting no call, then one more millisecond and asserting exactly one call, proves the timing precisely rather than approximately. last call +299 not called +300 called once two assertions bracket the deadline exactly a real-timer sleep of 350ms could not tell 300 from 1
Bracketing the deadline turns "roughly after a pause" into a precise, verifiable contract.

Step 5 — Test a debounced input in a React component. user-event must be told to advance fake timers, or typing will wait on timers that never run.

test('the search box requests results once after typing stops', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const onSearch = vi.fn();
  render(<SearchBox onSearch={onSearch} debounceMs={300} />);

  await user.type(screen.getByRole('searchbox'), 'mugs');
  expect(onSearch).not.toHaveBeenCalled();

  await act(() => vi.advanceTimersByTimeAsync(300));
  expect(onSearch).toHaveBeenCalledExactlyOnceWith('mugs');
});

Step 6 — Test that unmount cancels. Render, type, unmount before the deadline, advance past it, and assert nothing fired — the check that catches the “state update on an unmounted component” class of bug.

A final design note that makes these tests easier to write: keep the interval as a parameter rather than a constant buried in the component, even if production always passes the same value. A test can then use a small, obvious number and the component’s own tests can confirm the production value separately. It also makes the interval visible in code review, where “why 300 milliseconds?” is a question worth someone asking.

Verification

Confirm the tests pin behaviour rather than approximate it by making a one-character change: set the debounce to reset only on the first call instead of every call. The trailing-edge test must fail, because the call now fires 300 milliseconds after the first keystroke.

npx vitest run src/lib --reporter=verbose
# ✓ fires once, 300ms after the last call, with the last arguments
# ✓ fires by maxWait even while calls keep arriving
# ✓ runs on the leading edge, at most once per window, and once at the end
# ✓ cancel prevents a pending call

Then confirm no test sleeps for real: the whole file should complete in a few milliseconds. A timing-based test that takes hundreds of milliseconds has a real timer somewhere.

Options worth a test each Trailing and leading edges, maximum wait, and cancellation each change behaviour visibly and each deserve a dedicated test, because library defaults for them differ. trailing after the pause leading immediately maxWait guaranteed progress cancel no stale calls
Four options, four tests — each distinguishing behaviours that look similar from outside.

Troubleshooting

Symptom: user.type hangs under fake timers. Diagnosis: user-event inserts delays between keystrokes using timers that never advance. Fix: pass advanceTimers: vi.advanceTimersByTime to userEvent.setup, or set delay: null.

Symptom: the debounced call never fires even after advancing. Diagnosis: the component’s effect scheduled the timer asynchronously, after the synchronous advance. Fix: use advanceTimersByTimeAsync inside act, which flushes microtasks and React updates between timer callbacks.

Symptom: a library’s debounce ignores fake timers. Diagnosis: it captured a reference to the real setTimeout at import time, before fake timers were installed. Fix: install fake timers before importing the module, or use the library’s own clock injection if it offers one.

Symptom: a later test sees timers left by an earlier one. Diagnosis: pending timers were not flushed or cleared before restoring real timers. Fix: run or clear pending timers in afterEach, as the helper does, before useRealTimers.

FAQ

Should I test the library’s debounce or my usage of it?

Your usage — which interval, which edges, whether it cancels on unmount. The library’s own implementation is its maintainers’ concern. The tests above apply equally to a hand-written function or a library one, because they assert on the observable timing contract.

How do I test a debounced hook directly?

Render it with renderHook, call the returned function, and advance timers inside act. The same bracketing technique applies; the only addition is wrapping state updates so React processes them before the assertion.

What about requestAnimationFrame-based throttling?

Include requestAnimationFrame in the faked APIs and advance by frame duration, or use vi.advanceTimersToNextFrame. The principle is the same: time — here, frames — becomes an input the test controls.

Does this interact with fake Date?

Yes, usefully. Faking Date alongside timers keeps any Date.now() arithmetic inside the debounce, such as the max-wait calculation above, consistent with the advanced clock. Faking timers without Date produces a clock that disagrees with itself.