On this page · sections
JavaScript Debounce & Throttle
Can you tame a noisy event stream — wait for silence, or keep a steady pulse — without dropping
thisor leaking timers?
Level: Medium · Coding Round · Timers
Why This Question Matters
- Search, scroll, and resize fire faster than your API or layout can keep up.
- Interviewers use these two to test closures, timers, and
this, not lodash trivia. - Getting them swapped still ships: typeahead that hammers the network, or a sticky header that updates once and dies.
cancel(),flush(), and leading vs trailing are the senior follow-ups after the happy path.
Two problems on this page. Same closure. Different timer rules.
Problem 1 — Debounce
“Implement
debounce(fn, ms). Reset the timer on every call. Runfnonly after calls have stopped forms. Keep the latestthisand arguments. Exposecancel()andflush().”
function debounce(fn, ms) {}Trailing (default round)
- Each call clears the previous timer and starts a new one.
fnruns once aftermsof inactivity, with the last arguments andthis.- Capture
thiswhen the call is scheduled. InsidesetTimeout,thisis not the original caller.
cancel()
- Drop the pending invocation. Nothing should fire after that.
- Safe to call twice. Safe if nothing is pending.
flush()
- If a call is pending, run it now with the stored
thisand args, then clear the timer. - If nothing is pending, do nothing (or return
undefined). - After flush, there is no second fire when the old timer would have expired.
Clarify before you code
- Default is trailing (run after silence). Leading is a follow-up.
- Do not debounce inside React render. Debounce the event, then
setState. - Debounce does not abort an in-flight
fetch. Pair withAbortControllerif you need that.
Example
const search = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);
search("j");
search("ja");
search("jav");
search("java");Only "java" hits the API, 300ms after the last keystroke.
Unmount:
search.cancel();Force the pending search before leave:
search.flush();Call
↓
clear previous timer
↓
start new timer
↓
another call?
↓
yes → repeat
↓
no
↓
executeMental Model
pending = latest this + args + timer id
new call → replace pending
silence → invoke pending
cancel() → drop pending
flush() → invoke pending nowSolution
function debounce(fn, ms) {
let timer;
let lastCtx;
let lastArgs;
function invoke() {
const ctx = lastCtx;
const args = lastArgs;
timer = undefined;
lastCtx = undefined;
lastArgs = undefined;
return fn.apply(ctx, args);
}
function debounced(...args) {
lastCtx = this;
lastArgs = args;
clearTimeout(timer);
timer = setTimeout(invoke, ms);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = undefined;
lastCtx = undefined;
lastArgs = undefined;
};
debounced.flush = () => {
if (timer === undefined) {
return undefined;
}
clearTimeout(timer);
return invoke();
};
return debounced;
}Store this and args on every call. The timer always fires the latest snapshot.
Why This Works
Latest call wins
t=0 search("j") timer A
t=50 search("ja") clear A, timer B
t=80 search("java") clear B, timer C
t=380 silence fn("java")fn("j") never runs.
this is captured early
input.addEventListener("input", debounced);The browser sets this to the input. Save it before setTimeout.
flush vs waiting
flush is “I am leaving this screen, send the last query now.” cancel is “I am leaving, do not send it.”
Leading + trailing
“Also support
leading. First call in a burst runs immediately. Later calls still wait for silence if trailing is on.”
function debounce(fn, ms, options = {}) {
const leading = Boolean(options.leading);
const trailing = options.trailing !== false;
let timer;
let lastCtx;
let lastArgs;
function invoke() {
const ctx = lastCtx;
const args = lastArgs;
lastCtx = undefined;
lastArgs = undefined;
return fn.apply(ctx, args);
}
function schedule() {
timer = setTimeout(() => {
timer = undefined;
if (trailing && lastArgs) {
invoke();
}
}, ms);
}
function debounced(...args) {
lastCtx = this;
lastArgs = args;
const isLeadingCall = leading && timer === undefined;
clearTimeout(timer);
schedule();
if (isLeadingCall) {
return invoke();
}
}
debounced.cancel = () => {
clearTimeout(timer);
timer = undefined;
lastCtx = undefined;
lastArgs = undefined;
};
debounced.flush = () => {
if (timer === undefined) {
return undefined;
}
clearTimeout(timer);
timer = undefined;
if (lastArgs) {
return invoke();
}
};
return debounced;
}If both leading and trailing are on, one burst can fire twice: immediately, then after silence with the last args. Search usually wants trailing only. A “save” button that should react to the first click may want leading.
maxWait (lodash): if the user never stops typing, still fire at least every maxWait. Track lastInvoke and shorten the next timeout. Mention it; implement only if they ask.
Use cases
- Search / typeahead — trailing, 200–300ms
- Autosave — trailing,
flushon blur or unmount - Window resize after the user stops dragging
- Form validation after typing pauses
Problem 2 — Throttle
“Implement
throttle(fn, ms). At most one execution perms. Preservethisand arguments. Leading-edge first. Then add trailing so the last call in a window is not dropped.”
function throttle(fn, ms) {}Leading (starter)
- The first call in a window runs immediately.
- Later calls inside
msare ignored. - After the window, the next call runs again.
Trailing (senior)
- If calls happen while blocked, remember the latest
thisand args. - When the window ends, run that last call once.
- Scroll/drag UIs need this so the final position is not skipped.
Clarify before you code
- Throttle is not debounce. You still need updates during the gesture.
- This does not abort
fetch. It only skips starting extra work. requestAnimationFrameis a frame budget, not a millisecond throttle. Different tool.
Example
const handleScroll = throttle(() => {
console.log("scroll");
}, 100);
window.addEventListener("scroll", handleScroll);Dozens of scroll events. fn runs at most once per 100ms.
Debounce
─────────────────────────────
calls calls calls calls
↓
execute
Throttle
─────────────────────────────
execute
blocked
execute
blocked
executeLeading-only drop:
scroll …… last scroll (blocked) …… window ends
↓
nothing ← trailing would fire hereSolution — leading only
function throttle(fn, ms) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last < ms) {
return;
}
last = now;
return fn.apply(this, args);
};
}last = 0 means the first call always runs in any realistic clock (now is far larger than ms).
No pending timer. Simple, and the last event in a window is lost.
Solution — leading + trailing + cancel
function throttle(fn, ms) {
let last = 0;
let timer;
let lastCtx;
let lastArgs;
function invoke(time) {
last = time;
const ctx = lastCtx;
const args = lastArgs;
lastCtx = undefined;
lastArgs = undefined;
return fn.apply(ctx, args);
}
function throttled(...args) {
const now = Date.now();
lastCtx = this;
lastArgs = args;
const remaining = ms - (now - last);
if (remaining <= 0 || remaining > ms) {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
return invoke(now);
}
if (timer === undefined) {
timer = setTimeout(() => {
timer = undefined;
invoke(Date.now());
}, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timer);
timer = undefined;
lastCtx = undefined;
lastArgs = undefined;
last = 0;
};
return throttled;
}remaining > ms covers a clock jump backwards. Then treat it as “window is over, run now.”
Why This Works
Leading path: window expired → invoke immediately, clear any trailing timer.
Trailing path: still inside the window → one timer for remaining ms, always holding the latest args. Extra calls only replace lastCtx / lastArgs.
Use cases
scroll/mousemove/ drag — leading + trailing- Live layout measurement
- Rate-limit a “sync cursor” event
- Analytics beacons that should sample, not wait for silence
Side by side
| Utility | Completes when | Typical UI |
|---|---|---|
debounce | Silence for ms | Search, autosave |
throttle | At most once per ms | Scroll, resize, drag |
| Piece | Time | Space |
|---|---|---|
| Each call | O(1) | O(1) |
Failing to clearTimeout on debounce leaks a callback after unmount. Neither helper cancels a request that already left the browser.
Senior Follow-Ups
1. Debounce vs throttle — pick one
Q. Search box vs infinite scroll?
Answer: Search is debounce — you care about the last query after typing stops. Infinite scroll is throttle — you need regular samples while the user is still scrolling.
2. Why cancel()?
Q. Why not let the timer fire after unmount?
Answer: That setState / fetch runs on a dead component. cancel() in useEffect cleanup (or beforeunload) drops the pending call.
3. Why flush()?
Q. User hits Enter 50ms after the last key?
Answer: Do not wait the rest of the 300ms. flush() runs the pending search now. Same for “Save” on blur.
4. Leading debounce
Q. When would you run on the first call?
Answer: First keystroke shows a spinner immediately; later keys only refresh after silence. Or a “like” that must feel instant, then ignore doubles.
5. Both leading and trailing
Q. Can one burst fire twice?
Answer: Yes. Leading on the first call, trailing on the last after silence. Say that out loud so the interviewer knows you did not double-fetch by accident. Search usually sets trailing only.
6. Does throttle cancel fetch?
Q. If we skip a scroll handler, is the previous request aborted?
Answer: No. Timer helpers only decide whether to start fn. Abort needs AbortController inside fn.
7. this and arrows
Q. debounce(() => this.save())?
Answer: The wrapper still saves the caller’s this. An arrow fn ignores apply’s thisArg. Bind fn or do not use an arrow if this matters.
8. React 18 Strict Mode
Q. Why did my debounced effect fire twice in dev?
Answer: Strict Mode remounts. If you did not cancel on cleanup, the first mount’s timer can still run. Return debounced.cancel from the effect.
9. requestAnimationFrame vs throttle
Q. Should scroll use rAF instead?
Answer: rAF syncs to frames (~16ms). Throttle is an arbitrary ms budget (100ms, 1s). Use rAF to paint; use throttle to hit the network or a store. You can compose them.
10. What would you test?
Q. Fake timers — what cases?
Answer: At minimum:
✓ debounce: burst → one call with last args
✓ debounce: this preserved
✓ cancel() → fn never runs
✓ flush() → fn runs now, not again later
✓ throttle leading: first call immediate
✓ throttle: second call inside window ignored
✓ throttle trailing: last call runs when window ends
✓ ms = 0 still does not recurse foreverExample with fake timers in your head:
const fn = jest.fn();
const d = debounce(fn, 300);
d("a");
d("b");
jest.advanceTimersByTime(300);
// fn.mock.calls → [["b"]]Edge Cases
msof0: still async (timeout 0). Syncfn()on every call is not debounce.- Rapid
cancelthen new call: new timer starts clean. flushwhen idle: no-op.- Clock going backwards: throttle’s
remaining > mspath runs immediately. - Returning a value: trailing debounce’s return is usually
undefinedon the outer call becausefnruns later.flush()can returnfn’s result. - Lodash
maxWait: without it, a user who never pauses never triggers trailing debounce.
Complexity
The wrapper is O(1) per call. The cost that matters is whatever fn does (layout, network).
Do not create a new debounce function on every React render — you reset the closure and the timer. useMemo / useRef the wrapper, or debounce in an event handler module.
Senior Engineer Interview Answer
“Debounce waits for silence: every call resets the timer, I store the latest this and args, I expose cancel and flush. Default is trailing; leading is optional and can double-fire with trailing. Throttle limits frequency: leading-edge runs immediately; trailing remembers the last blocked call so scroll does not skip the final position. Search is debounce. Scroll is throttle. Neither aborts a request that already left the browser.”
Interview Checklist
✓ debounce resets the timer
✓ last this + args
✓ cancel()
✓ flush()
✓ leading vs trailing
✓ throttle leading window
✓ throttle trailing last call
✓ apply(this, args)
✓ debounce ≠ throttle
✓ no timer leak
✓ not AbortController
✓ stable wrapper in ReactSame closure. Two stop rules. One is silence. One is a budget.