Topics
v6.0.0
On this page · sections

JavaScript Debounce & Throttle

Can you tame a noisy event stream — wait for silence, or keep a steady pulse — without dropping this or 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. Run fn only after calls have stopped for ms. Keep the latest this and arguments. Expose cancel() and flush().”

function debounce(fn, ms) {}

Trailing (default round)

  • Each call clears the previous timer and starts a new one.
  • fn runs once after ms of inactivity, with the last arguments and this.
  • Capture this when the call is scheduled. Inside setTimeout, this is 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 this and 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 with AbortController if 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 ↓ execute

Mental Model

pending = latest this + args + timer id new call → replace pending silence → invoke pending cancel() → drop pending flush() → invoke pending now

Solution

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, flush on 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 per ms. Preserve this and 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 ms are ignored.
  • After the window, the next call runs again.

Trailing (senior)

  • If calls happen while blocked, remember the latest this and 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.
  • requestAnimationFrame is 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 execute

Leading-only drop:

scroll …… last scroll (blocked) …… window ends ↓ nothing ← trailing would fire here

Solution — 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

UtilityCompletes whenTypical UI
debounceSilence for msSearch, autosave
throttleAt most once per msScroll, resize, drag
PieceTimeSpace
Each callO(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 forever

Example 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

  • ms of 0: still async (timeout 0). Sync fn() on every call is not debounce.
  • Rapid cancel then new call: new timer starts clean.
  • flush when idle: no-op.
  • Clock going backwards: throttle’s remaining > ms path runs immediately.
  • Returning a value: trailing debounce’s return is usually undefined on the outer call because fn runs later. flush() can return fn’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 React

Same closure. Two stop rules. One is silence. One is a budget.

Last updated on