JavaScript debounce interview question: solution and tests
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.
The question
Implement debounce(callback, wait) without a library. Each call must restart the delay, and the callback must receive the latest arguments and the calling this value. Add cancel() to discard a pending call and flush() to run it immediately.
Start by stating the contract: this is trailing-only debounce, and cancel or flush with nothing pending does nothing. The wrapper and flush in this exercise do not return the callback result. Leading calls, maxWait and result caching are separate extensions.
Compare the timing with the JavaScript throttle interview question: debounce waits for quiet, while throttle allows updates during a burst. Both use closures; implementing memoize exercises the same idea with cached results.
Try implementing it before reading the solution. The JavaScript interview practice editor lets you write your answer and run the question’s tests.
Trace the calls before writing the code
Use a 100 ms wait and call the same debounced function with A at 0 ms, B at 40 ms and C at 90 ms. This exercise uses trailing-only debounce.
| Time | Action | Callback |
|---|---|---|
| 0 ms | Call with A | None; schedule A for 100 ms. |
| 40 ms | Call with B | None; replace the timer with B at 140 ms. |
| 90 ms | Call with C | None; replace the timer with C at 190 ms. |
| 190 ms | No further calls | Run once with C. |
In separate runs, cancel at 120 ms should produce no callback; flush at 120 ms should run C immediately and prevent a second call at 190 ms. These are ideal clock times: a busy event loop can delay real timers.
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.
✓ 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");✓ 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);✓ 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.
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 it runs after a burst stops; an unending burst can postpone it indefinitely. 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.
Related interview questions
- JavaScript throttle interview question: solution and tests — Run at most once per window, however often it’s called.
- Implement memoize in JavaScript — Cache results by argument, including undefined ones.
- Implement curry in JavaScript — Collect arguments across calls until the function is satisfied.
Practise this with the tests running
Practice Pad runs these 9 cases against your own solution, in a real editor, in your browser or on your desktop. Twenty-four questions, and an AI panel that explains why a test failed.