Implement debounce in JavaScript

Debounce wraps a function so that rapid, repeated calls collapse into a single call, made only once the caller has stopped calling it for a set wait. Keep a timer id in a closure, clear it on every call, and schedule a fresh timer each time. The call that finally runs uses the arguments from the most recent invocation.

Easy to hard closures timers higher-order functions

The question

Interviewers rarely say "implement debounce" and leave it there. It comes in three stages: write debounce(callback, wait) so a burst of calls produces one call, add cancel() to throw away a call that hasn’t run yet, then add flush() to run the pending call immediately.

Stage one is a warm-up. Two and three are where people come unstuck, because both need the returned function to carry state the caller can reach.

The solution

Because cancel() and flush() both need to reach the pending call, the arguments are stored explicitly rather than captured in a per-call closure.

function debounce(callback, wait) {
  let timeoutId = null;
  let pendingArgs = null;
  let pendingThis = null;

  function invoke() {
    const args = pendingArgs;
    const context = pendingThis;
    // Clear before calling, so a callback that re-enters sees a clean slate.
    timeoutId = null;
    pendingArgs = null;
    pendingThis = null;
    callback.apply(context, args);
  }

  function debounced(...args) {
    pendingArgs = args;
    pendingThis = this;
    if (timeoutId !== null) clearTimeout(timeoutId);
    timeoutId = setTimeout(invoke, wait);
  }

  debounced.cancel = () => {
    if (timeoutId !== null) clearTimeout(timeoutId);
    timeoutId = null;
    pendingArgs = null;
    pendingThis = null;
  };

  debounced.flush = () => {
    if (timeoutId === null) return;
    clearTimeout(timeoutId);
    invoke();
  };

  return debounced;
}

How it works

The timer is the whole mechanism. Every call clears the previous one and starts another, so the callback only fires once wait milliseconds have gone by with nobody calling it. pendingArgs gets overwritten each time, which is why the call that eventually runs uses the most recent arguments rather than the first.

flush() is synchronous, and that matters. It clears the timer and calls invoke() right there. Schedule a zero-delay timer instead and you’ve missed the point: flush means now, not on the next turn of the event loop. cancel() is the quieter one. It throws away the pending state without disabling anything, so the next call schedules as normal.

The tests it has to pass

This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 9 cases Practice Pad runs against your solution, and the implementation above passes all of them.

Easy3 cases · the timer behaviour

does not call through synchronously

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 20);
debounced();
expectEqual(calls, 0);

collapses a burst of calls into a single call

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 20);
debounced();
debounced();
debounced();
await delay(60);
expectEqual(calls, 1);

calls through with the arguments of the last call

let seen;
const debounced = debounce((value) => { seen = value; }, 20);
debounced("first");
debounced("last");
await delay(60);
expectEqual(seen, "last");
Medium3 cases · cancel()

cancel stops a pending call from ever running

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 30);
debounced();
debounced.cancel?.();
await delay(90);
expectEqual(calls, 0);

cancel leaves the function usable for later calls

const seen = [];
const debounced = debounce((value) => { seen.push(value); }, 30);
debounced("dropped");
debounced.cancel?.();
await delay(60);
debounced("kept");
await delay(90);
expectEqual(seen, ["kept"]);

cancel with nothing pending changes nothing

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 20);
debounced.cancel?.();
debounced();
await delay(60);
expectEqual(calls, 1);
Hard3 cases · flush()

flush runs a pending call at once, with the latest arguments

const seen = [];
const debounced = debounce((value) => { seen.push(value); }, 100);
debounced("first");
debounced("last");
debounced.flush?.();
// Read synchronously: flush means now, not on a later turn of the timer.
expectEqual(seen, ["last"]);

a flushed call does not run again when the wait elapses

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 30);
debounced();
debounced.flush?.();
await delay(90);
expectEqual(calls, 1);

flush with nothing pending does not call through

let calls = 0;
const debounced = debounce(() => { calls += 1; }, 20);
debounced.flush?.();
expectEqual(calls, 0);
debounced();
await delay(60);
expectEqual(calls, 1);

Edge cases to watch out for

Returning an arrow function

The single most common failure. An arrow function has no this of its own, so the moment the debounced function is used as a method, the callback receives the wrong receiver.

// Broken: obj.handler() cannot see obj
const debounce = (callback, wait) => {
  let timeoutId = null;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => callback(...args), wait);
  };
};

Capturing the arguments once instead of on every call

If the arguments are read when the debounced function is created rather than when it’s called, the callback runs with stale values. The third easy test exists to catch exactly this.

Making flush asynchronous

Reaching for setTimeout(invoke, 0) inside flush() feels harmless and fails the first hard test, which reads the result synchronously on the next line. If a caller wanted to wait, they wouldn’t have called flush.

Forgetting the timer is still armed after flush

Calling invoke() without clearing the timer first means the callback runs twice: once on flush, and again when the original wait elapses.

Worth saying out loud. A production debounce, such as lodash's, also supports a leading edge call and a maxWait. You’ll rarely be asked to implement those, but mentioning they exist signals you know the real thing rather than just the interview version.

Follow up questions

What’s the difference between debounce and throttle?

Debounce waits for quiet: it runs once the calls stop for the full wait, so a continuous burst produces exactly one call at the end. Throttle rations: it runs at most once per window, so a continuous burst produces a steady drip. Use debounce for typeahead search, and throttle for scroll and resize handlers.

How would you test a debounce?

With fake timers, so the test doesn’t actually wait. Jest and Vitest both let you swap the clock, call the debounced function, advance time by the wait, and assert on the callback. Real timers make the suite slow and flaky, because a test written around a 300ms wait fails the first time CI is busy.

How would you add a leading edge option?

Track whether a window is currently open. On a call with nothing pending and leading turned on, invoke immediately and then start the timer; on later calls inside the window, fall back to the trailing behaviour. The awkward case is both edges enabled with a single call, which must fire once rather than twice.

How do I implement debounce in React?

Create the debounced function once and keep it stable across renders, with useMemo or useRef. Otherwise every render builds a new debounced function with its own timer and nothing is ever actually debounced, which is a genuinely common production bug. Cancel the pending call in the effect cleanup so it can’t fire after unmount.

What wait time should a debounce use?

For typeahead search, 150 to 300 milliseconds is usual: long enough to collapse ordinary typing into one request, short enough that results don’t feel delayed. For autosaving a draft, one to two seconds is more typical. In an interview the number matters far less than explaining the trade-off.

Practise this with the tests running

Practice Pad runs these 9 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.

Download for macOS Free. Requires macOS 12 or later on Apple Silicon.

See all 24 questions