Custom setTimeout
advancedRebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.
Implement throttle in JavaScript with timestamps, a correct trailing edge, leading/trailing options and cancel — including the stale-closure bug in the snippet everyone copies.
Implement
throttle(fn, wait): during a stream of calls, runfnat most once perwaitms — first call immediately (leading edge), and after the stream pauses, one final call with the latest arguments (trailing edge).
Throttle guarantees a rate during activity; debounce waits for activity to end. Scroll handlers are the canonical case: with debounce you'd get nothing during a 10-second scroll; with throttle you get a steady tick. That distinction — and a trailing edge that actually works — is what this question grades.
Fair warning: the most widely copied "advanced throttle" snippet on the internet has a broken trailing edge. It's dissected below, because interviewers paste it deliberately.
Timestamps for the leading edge, one timer for the trailing edge:
function throttle(fn, wait, { leading = true, trailing = true } = {}) {
let lastInvoke = 0; // when fn last actually ran (0 = never)
let timerId = null; // pending trailing-edge timer
let pendingArgs = null;
let pendingThis = null;
const invoke = () => {
lastInvoke = Date.now(); // read the clock AT INVOKE TIME — see the broken version
timerId = null;
fn.apply(pendingThis, pendingArgs);
pendingArgs = pendingThis = null;
};
function throttled(...args) {
const now = Date.now();
let remaining = wait - (now - lastInvoke);
// leading disabled: a fresh burst starts a full window instead of firing now
if (remaining <= 0 && !leading) {
lastInvoke = now;
remaining = wait;
}
pendingArgs = args;
pendingThis = this;
if (remaining <= 0) {
clearTimeout(timerId);
invoke(); // leading edge: window expired → fire now
} else if (trailing && timerId === null) {
timerId = setTimeout(invoke, remaining); // trailing edge: fire when window closes
}
// else: mid-window with a timer already set — just updated pendingArgs;
// the trailing fire will use the LATEST arguments
}
throttled.cancel = () => {
clearTimeout(timerId);
timerId = null;
lastInvoke = 0;
pendingArgs = pendingThis = null;
};
return throttled;
}t=0 call → lastInvoke=0 means "long ago" → INVOKE (leading)
t=100…900 calls → remaining>0 → first one sets trailing timer for t=1000,
each updates pendingArgs to the freshest event
t=1000 timer fires → INVOKE with t=900's args; lastInvoke=1000
t=1100…1900 calls → new trailing timer for t=2000
t=2000 INVOKE (args from t=1900); stream ends at t=2500
t=2500 last call → timer set for t=3000
t=3000 INVOKE — the trailing call that reports the FINAL scroll positionSteady once-per-second during the stream, plus the final-position call. That last invoke matters in practice: without a trailing edge, whatever you're syncing to scroll position stops one window short of where the user actually ended up.
// ❌ the snippet on half the blogs of the internet
function throttle(func, limit) {
let lastFunc, lastRan;
return function (...args) {
const now = Date.now(); // captured ONCE, at call time
if (!lastRan) {
func.apply(this, args);
lastRan = now;
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(() => {
if (now - lastRan >= limit) { // ← stale `now` from BEFORE the wait
func.apply(this, args);
lastRan = now;
}
}, limit - (now - lastRan));
}
};
}Follow the values: we're in the else branch because now - lastRan < limit. The timeout fires later — but the condition re-checks the same frozen now, so it's still < limit, still false, and the trailing call never runs (or runs erratically when overlapping calls happen to refresh the closure). It's a stale-closure bug wearing a throttle costume — which is exactly why interviewers hand it to you as a "here's some code, what's wrong with it" question. The fix is the timestamp discipline in the main implementation: read the clock inside the timer callback, at invoke time.
Two more defects in that snippet worth naming in review: with leading: false-style needs it can't comply at all, and options = { leading: true, trailing: true } as a default parameter (in the common variant) means passing { leading: false } silently discards trailing — destructuring with per-key defaults (as in the main version) avoids it.
pendingArgs; the trailing invoke reports the freshest state. Firing with the first call's args is a subtle correctness bug (your UI syncs to a stale scroll position).leading: false, trailing: false — a function that can never fire; degenerate but should be known, not discovered.wait — one leading call plus one trailing call; if the burst was a single call, no trailing duplicate (nulled pendingArgs guards it — same discipline as debounce's double-fire guard).Date.now() jumps with system clock changes; performance.now() is monotonic. For UI throttling it rarely matters; knowing the difference does.requestAnimationFrame is a frame-rate throttle with better scheduling than any timer; "for paint-synced work I'd use rAF instead" is a strong aside (see custom setTimeout for the rAF/timer relationship).now trailing bug (above) — memorized from a blog, reproduced under pressure, caught in one question.inThrottle) presented as complete — it silently drops the trailing edge, losing the final state.cancel — unmounting components need to kill the pending trailing call (same React wiring as debounce).setTimeout sleeps instead of fake timers.Console
Press Run to execute this snippet in a sandboxed worker.
| Debounce | Throttle | |
|---|---|---|
| Model | reset the timer on every call | fixed-rate windows |
| During continuous activity | silent | steady invocations |
| After activity stops | one call | one trailing call |
| Use | typing, autosave | scroll, mousemove, resize, drag |
The unifying insight interviewers reward: throttle ≈ debounce with maxWait = wait — lodash implements it exactly that way. If you've built debounce with maxWait, you've built throttle.
maxWait: wait through; explain why the guarantee becomes a rate.invoke on the next frame if not already scheduled; auto-adapts to display refresh and pauses in background tabs.visibilitychange and flush.wait, assert exactly two calls and that the second received t=100's arguments — the assertion that catches the broken snippet above.Rebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.
Implement debounce from scratch, with leading/trailing options and the classic search-input use case.
Implement setInterval with setTimeout recursion — and fix the drift problems of the native version.
Track and clear every active timeout and interval — a utility question about monkey-patching globals safely.