beginnerVery common6 min read · Updated Jul 18, 2026

Implement Debounce (with Leading, Trailing, Cancel, and Flush)

Implement debounce in JavaScript from scratch: leading/trailing edges, cancel and flush methods, this preservation, and the React cleanup pitfall — the most-asked frontend interview question.


The problem

Implement debounce(fn, wait): calling the returned function repeatedly keeps postponing fn; it only runs after wait ms of silence. Then extend it with leading/trailing options and cancel/flush — because the extensions are where the actual interview happens.

Debounce is the most frequently asked frontend implementation question, period. It's a pure closure exercise (state = one timer id), it has a this trap, and it escalates naturally. The one-liner mental model: debounce waits for the burst to end. (Its sibling throttle guarantees a rate during the burst — the comparison at the bottom is a guaranteed follow-up.)

Where you see it in production

  • Search-as-you-type — fire the request when the user pauses, not per keystroke.
  • Autosave — save 500ms after the last edit.
  • Form validation — don't flash "invalid email" while they're mid-typing.
  • Window resize — recompute layout once the drag ends.

Core implementation

function debounce(fn, wait) {
  let timerId = null; // the closure state — shared across all calls
 
  return function (...args) {
    clearTimeout(timerId); // kill the previous schedule…
    timerId = setTimeout(() => {
      fn.apply(this, args); // …and re-schedule with the LATEST args & this
    }, wait);
  };
}

Two details that distinguish a correct answer:

  1. function, not an arrow, for the wrapper — so this flows from the call site (input.addEventListener("input", debounced) calls it with this === input). The inner arrow then inherits that this lexically. Mixing these up is the classic planted bug.
  2. clearTimeout(undefined) is a no-op — so the first call needs no special case. Small, but interviewers notice candidates who add needless if (timerId) guards without knowing they're needless.

Dry run (wait = 300)

t=0    call("a") → schedule fn("a") at t=300
t=100  call("ab") → cancel that, schedule fn("ab") at t=400
t=250  call("abc") → cancel, schedule fn("abc") at t=550
t=550  silence held → fn("abc") runs — once, with the FINAL args

Full version: leading, trailing, cancel, flush

The real lodash-style contract — and each piece is a known follow-up:

function debounce(fn, wait, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let pendingArgs = null;
  let pendingThis = null;
  let result;
 
  const invoke = () => {
    result = fn.apply(pendingThis, pendingArgs);
    pendingArgs = pendingThis = null; // consumed — prevents a duplicate trailing fire
  };
 
  function debounced(...args) {
    pendingArgs = args;
    pendingThis = this;
 
    const callNow = leading && timerId === null; // leading fires only on a FRESH burst
 
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      // trailing fires only if calls arrived after the leading invoke
      if (trailing && pendingArgs !== null) invoke();
    }, wait);
 
    if (callNow) invoke();
    return result; // latest available result (undefined until first invoke)
  }
 
  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = null;
    pendingArgs = pendingThis = null;
  };
 
  debounced.flush = () => {
    // "I can't wait any longer" — run the pending call NOW (e.g. on unmount/submit)
    if (timerId !== null && pendingArgs !== null) {
      clearTimeout(timerId);
      timerId = null;
      invoke();
    }
    return result;
  };
 
  return debounced;
}

The subtle correctness detail: after a leading invoke, pendingArgs is nulled — so if no further calls arrive during the window, the trailing edge correctly does nothing (a single call must not run fn twice). Checking pendingArgs !== null in the timer is what encodes that; most hand-rolled leading implementations fire twice and fail this exact test.

The React pitfall (asked constantly now)

// ❌ new debounced function every render — timer state resets, nothing debounces
<input onChange={debounce(handleChange, 300)} />
 
// ✅ create once, cancel on unmount
const debouncedSave = useMemo(() => debounce(save, 300), [save]);
useEffect(() => () => debouncedSave.cancel(), [debouncedSave]);

The debounced function's power is its closure state; recreate it per render and every keystroke gets a fresh, empty timer. And without cancel on unmount, the pending timer fires after the component is gone — setting state on an unmounted component or saving stale data. This pairing (memoize + cancel-on-cleanup) is why cancel exists, and interviewers increasingly ask the React wiring rather than the utility itself.

Edge cases interviewers probe

  • Latest args win — the trailing call uses the args of the last invocation; storing them per-call instead of in the closure is the tell that the model hasn't clicked.
  • Return values are awkward by design — the caller gets the previous result (or undefined); the real result arrives later. If the caller needs it, the API should hand back a promise instead — a redesign question, and "debounce + promises changes the contract" is the right instinct.
  • leading: true, trailing: false — "fire immediately, ignore the rest of the burst" — button double-click protection.
  • Zero/negative wait — still asynchronous (timer clamps to 0), never synchronous; matching timer semantics beats "optimizing" into a sync call.
  • Timers are inexactwait: 300 means no sooner than ~300ms; background tabs throttle timers to 1s+ (how timers really schedule).

Common mistakes

  • Arrow function as the wrapper (loses this and arguments).
  • Recreating the debounced function per call site/render (see React section).
  • Leading implementations that double-fire on a single call.
  • Forgetting cancel/flush even exist — the difference between a snippet and an API.
  • Testing with real timers and sleeps — use fake timers (vi.useFakeTimers() / jest.useFakeTimers()), advance time explicitly, and assert call counts; this is also the standard "how would you test it?" answer.

Test yourself

Try it yourself

Console

Press Run to execute this snippet in a sandboxed worker.

Debounce vs throttle

DebounceThrottle
Guaranteesruns after the burst endsruns at most once per wait during the burst
During a 10s stream of eventspossibly zero calls until it ends~one call per window, steadily
Canonical usesearch input, autosavescroll position, mousemove, drag
Relationshipthrottle ≈ debounce with maxWait = wait (lodash literally implements it this way)

If continuous feedback during the activity matters → throttle. If only the final state matters → debounce.

Follow-up questions

  • "Add maxWait" — debounce that can't starve: guarantee an invoke at least every maxWait ms during a nonstop burst. Track last-invoke time; when exceeded, invoke despite the resets. This is exactly how lodash unifies the two utilities (throttle = debounce + maxWait).
  • "Make it return a promise." — each call returns a promise settled when the pending invoke runs; superseded calls either share the winner's promise or reject — you must choose and state the contract (deferred pattern helps).
  • "Debounce an async function safely?" — debouncing limits starts; stale responses can still arrive out of order. Pair with request cancellation or latest-wins guards (memoizeLast thinking).
  • "TypeScript signature?"debounce<T extends (...args: any[]) => any>(fn: T, wait: number): ((...args: Parameters<T>) => void) & { cancel(): void; flush(): ReturnType<T> | undefined } — the honest void/undefined returns encode the "return values are awkward" reality in types.
  • "Where does the browser make this obsolete?"scroll/resize observation is better served by IntersectionObserver/ResizeObserver (batched by the browser); debounce remains for semantic pauses like typing.

  • Throttle

    beginner

    Implement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).

  • Rebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.

  • Custom setInterval

    intermediate

    Implement setInterval with setTimeout recursion — and fix the drift problems of the native version.

  • clearAllTimers

    intermediate

    Track and clear every active timeout and interval — a utility question about monkey-patching globals safely.