Throttle
beginnerImplement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).
Implement debounce in JavaScript from scratch: leading/trailing edges, cancel and flush methods, this preservation, and the React cleanup pitfall — the most-asked frontend interview question.
Implement
debounce(fn, wait): calling the returned function repeatedly keeps postponingfn; it only runs afterwaitms of silence. Then extend it with leading/trailing options andcancel/flush— because the extensions are where the actual interview happens.
Debounce is the most frequently asked frontend implementation question, period. It's a pure closure exercise (state = one timer id), it has a this trap, and it escalates naturally. The one-liner mental model: debounce waits for the burst to end. (Its sibling throttle guarantees a rate during the burst — the comparison at the bottom is a guaranteed follow-up.)
function debounce(fn, wait) {
let timerId = null; // the closure state — shared across all calls
return function (...args) {
clearTimeout(timerId); // kill the previous schedule…
timerId = setTimeout(() => {
fn.apply(this, args); // …and re-schedule with the LATEST args & this
}, wait);
};
}Two details that distinguish a correct answer:
function, not an arrow, for the wrapper — so this flows from the call site (input.addEventListener("input", debounced) calls it with this === input). The inner arrow then inherits that this lexically. Mixing these up is the classic planted bug.clearTimeout(undefined) is a no-op — so the first call needs no special case. Small, but interviewers notice candidates who add needless if (timerId) guards without knowing they're needless.t=0 call("a") → schedule fn("a") at t=300
t=100 call("ab") → cancel that, schedule fn("ab") at t=400
t=250 call("abc") → cancel, schedule fn("abc") at t=550
t=550 silence held → fn("abc") runs — once, with the FINAL argsThe real lodash-style contract — and each piece is a known follow-up:
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timerId = null;
let pendingArgs = null;
let pendingThis = null;
let result;
const invoke = () => {
result = fn.apply(pendingThis, pendingArgs);
pendingArgs = pendingThis = null; // consumed — prevents a duplicate trailing fire
};
function debounced(...args) {
pendingArgs = args;
pendingThis = this;
const callNow = leading && timerId === null; // leading fires only on a FRESH burst
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
// trailing fires only if calls arrived after the leading invoke
if (trailing && pendingArgs !== null) invoke();
}, wait);
if (callNow) invoke();
return result; // latest available result (undefined until first invoke)
}
debounced.cancel = () => {
clearTimeout(timerId);
timerId = null;
pendingArgs = pendingThis = null;
};
debounced.flush = () => {
// "I can't wait any longer" — run the pending call NOW (e.g. on unmount/submit)
if (timerId !== null && pendingArgs !== null) {
clearTimeout(timerId);
timerId = null;
invoke();
}
return result;
};
return debounced;
}The subtle correctness detail: after a leading invoke, pendingArgs is nulled — so if no further calls arrive during the window, the trailing edge correctly does nothing (a single call must not run fn twice). Checking pendingArgs !== null in the timer is what encodes that; most hand-rolled leading implementations fire twice and fail this exact test.
// ❌ new debounced function every render — timer state resets, nothing debounces
<input onChange={debounce(handleChange, 300)} />
// ✅ create once, cancel on unmount
const debouncedSave = useMemo(() => debounce(save, 300), [save]);
useEffect(() => () => debouncedSave.cancel(), [debouncedSave]);The debounced function's power is its closure state; recreate it per render and every keystroke gets a fresh, empty timer. And without cancel on unmount, the pending timer fires after the component is gone — setting state on an unmounted component or saving stale data. This pairing (memoize + cancel-on-cleanup) is why cancel exists, and interviewers increasingly ask the React wiring rather than the utility itself.
undefined); the real result arrives later. If the caller needs it, the API should hand back a promise instead — a redesign question, and "debounce + promises changes the contract" is the right instinct.leading: true, trailing: false — "fire immediately, ignore the rest of the burst" — button double-click protection.wait — still asynchronous (timer clamps to 0), never synchronous; matching timer semantics beats "optimizing" into a sync call.wait: 300 means no sooner than ~300ms; background tabs throttle timers to 1s+ (how timers really schedule).this and arguments).cancel/flush even exist — the difference between a snippet and an API.vi.useFakeTimers() / jest.useFakeTimers()), advance time explicitly, and assert call counts; this is also the standard "how would you test it?" answer.Console
Press Run to execute this snippet in a sandboxed worker.
| Debounce | Throttle | |
|---|---|---|
| Guarantees | runs after the burst ends | runs at most once per wait during the burst |
| During a 10s stream of events | possibly zero calls until it ends | ~one call per window, steadily |
| Canonical use | search input, autosave | scroll position, mousemove, drag |
| Relationship | — | throttle ≈ debounce with maxWait = wait (lodash literally implements it this way) |
If continuous feedback during the activity matters → throttle. If only the final state matters → debounce.
maxWait" — debounce that can't starve: guarantee an invoke at least every maxWait ms during a nonstop burst. Track last-invoke time; when exceeded, invoke despite the resets. This is exactly how lodash unifies the two utilities (throttle = debounce + maxWait).debounce<T extends (...args: any[]) => any>(fn: T, wait: number): ((...args: Parameters<T>) => void) & { cancel(): void; flush(): ReturnType<T> | undefined } — the honest void/undefined returns encode the "return values are awkward" reality in types.scroll/resize observation is better served by IntersectionObserver/ResizeObserver (batched by the browser); debounce remains for semantic pauses like typing.Implement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).
Rebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.
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.