ESC

Type to search the knowledge base.

Fake Timers for Debounce

Test debounced and throttled UI with Vitest/Jest fake timers — advance time deterministically without real sleeps.

intermediate3 min read
  • testing
  • fake-timers

Debounced search, autosave, and tooltip delays depend on setTimeout. Real timers make tests slow and flaky. Fake timers replace the clock so you advance milliseconds instantly.

Docs: Vitest fake timers, Jest modern timers.

Debounce under test

export function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number) {
  let t: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}
import { vi, afterEach, beforeEach, expect, test } from 'vitest';

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

test('debounce only calls once after quiet period', () => {
  const spy = vi.fn();
  const d = debounce(spy, 300);

  d('a');
  d('ab');
  d('abc');
  expect(spy).not.toHaveBeenCalled();

  vi.advanceTimersByTime(300);
  expect(spy).toHaveBeenCalledTimes(1);
  expect(spy).toHaveBeenCalledWith('abc');
});

React Testing Library + timers

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

test('search debounces', async () => {
  vi.useFakeTimers();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const onSearch = vi.fn();
  render(<SearchBox onSearch={onSearch} />);

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

  await vi.advanceTimersByTimeAsync(300);
  expect(onSearch).toHaveBeenCalledWith('cat');

  vi.useRealTimers();
});

Important: user-event needs advanceTimers wired when fake timers are on — otherwise typing hangs.

advanceTimersByTime vs runAllTimers

API Use
advanceTimersByTime(ms) Precise debounce windows
runOnlyPendingTimers Flush current queue once
runAllTimers Risk infinite loops if recurring timers

Prefer advancing by known debounce intervals.

Throttle note

Throttle fires immediately then ignores — assert both leading call and suppressed calls before window ends.

Don’t fake timers globally by default

Some libraries use timers for batching (React, animations). Scope fakes to tests that need them; always restore real timers in afterEach.

Footguns

  1. Forgetting to restore real timers — later tests break.
  2. Mixing async user-event without advanceTimers.
  3. Using real sleep in unit tests.
  4. Microtask ordering — sometimes need await vi.runAllTicks() / flush promises after timer advance.
vi.advanceTimersByTime(300);
await Promise.resolve(); // flush then microtasks if needed

Interview out-loud answer

“I test debounce with fake timers: call the function repeatedly, advance time by the delay, assert a single invocation with the last args. I wire user-event to advanceTimers and always restore real timers after the test.”

Recurring timers

Intervals (setInterval) under fake timers need vi.advanceTimersByTime in multiples of the interval. Prefer clearing intervals in afterEach. For React 18 concurrent features, prefer advanceTimersByTimeAsync and await microtasks when assertions flake.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Implementation notes

Wire this into real code on the next feature, not only a demo. Prefer the smallest change that encodes the rule — a shared helper, a lint rule, or a checklist item in the PR template. Revisit after a week of production traffic: if users or tests still hit the failure mode, the documentation (and the abstraction) are not sharp enough yet.

Further reading

Related guides