Debounce and throttle are among the most common “implement this” questions in frontend rounds, because they look like five lines of code but quietly test closures, this, timers and edge cases. This note builds both from scratch, starting with the minimal version and ending with the options an interviewer usually asks for next: cancellation, a leading edge and a trailing call that never loses the last event.
Two strategies, one timeline
Both wrap a function and control how often it really runs when it is called in bursts.
- Debounce waits until the calls stop for
waitms, then runs once with the latest arguments. Every new call resets the timer. - Throttle runs at most once per
waitms, however many calls arrive.
Picture a user typing “react” into a search box: keystrokes at 0, 30, 60 and 90 ms, a pause, then the last key at 300 ms. With wait = 100, the implementations in this note produce:
| Strategy (wait = 100 ms) | The wrapped function runs at |
|---|---|
| Debounce (trailing, the default) | 190 ms with “reac”, 400 ms with “react” |
| Debounce (leading) | 0 ms with “r”, 300 ms with “react” |
| Throttle (leading + trailing) | 0 ms with “r”, 100 ms with “reac”, 300 ms with “react” |
Now a continuous stream: a scroll event every 20 ms from 0 to 240 ms. Debounce runs once, at 340 ms. Throttle runs at 0, 100, 200 and 300 ms. That is the whole difference: during a long burst, debounce stays silent, while throttle guarantees steady progress.
When to reach for which
| Use case | Pick | Why |
|---|---|---|
| Search-as-you-type, autocomplete | Debounce | Only the final query matters; saves requests |
| Validate or autosave while typing | Debounce | Run once the user pauses |
| Submit button, double-click guard | Debounce with leading: true, trailing: false |
Fire at once, ignore the rest of the burst |
| Scroll spy, infinite scroll check | Throttle | Needs updates during the scroll |
| Window resize | Throttle (or debounce for the final size only) | Depends on whether in-between sizes matter |
| Pointer tracking, drag analytics | Throttle | A steady sample rate is enough |
For purely visual work tied to scroll or resize, requestAnimationFrame is often the better throttle (at most once per frame), and ResizeObserver beats listening to the window resize event when you care about an element’s size.
Interview tip
Open with one sentence: debounce delays until the user stops; throttle guarantees a steady rate. Then give one use case for each. It shows you know why both exist before you write any code.
Debounce, step by step
Version 1: the core idea
function debounce(fn, wait) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), wait);
};
}The returned function is a closure over timerId (the same mechanism behind closures in a setTimeout loop). Each call cancels the pending timer and schedules a new one, so fn only runs once the calls stop for wait ms. Two details matter:
- The wrapper is a regular
function, so it receives whateverthisthe caller used (binding rules here). The arrow function insidesetTimeouthas nothisof its own, so it sees the wrapper’sthisandargs, andapplyforwards both. clearTimeout(undefined)is a harmless no-op, so the first call needs no special case.
Version 2: leading edge and cancel()
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timerId = null;
let lastArgs = null;
let lastThis = null;
function invoke() {
const args = lastArgs;
const ctx = lastThis;
lastArgs = lastThis = null;
fn.apply(ctx, args);
}
function debounced(...args) {
lastArgs = args;
lastThis = this;
const isNewBurst = timerId === null;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
if (trailing && lastArgs) invoke();
}, wait);
if (leading && isNewBurst) invoke();
}
debounced.cancel = () => {
clearTimeout(timerId);
timerId = lastArgs = lastThis = null;
};
return debounced;
}lastArgsandlastThisalways hold the most recent call, so the trailing invocation uses fresh data.isNewBurstis true when no timer is pending: that is the moment a leading call fires.- With
{ leading: true }(trailing still on), a single click runsfnonce, not twice:invoke()clearslastArgs, so the trailing timer finds nothing to do. A real burst runs on both edges. Lodash behaves the same way. cancel()drops the timer and the pending arguments. Call it on unmount or route change.
const search = debounce((q) => console.log('search:', q), 300);
search('r');
search('re');
search('rea');
// after 300 ms of silence: search: reaGotcha
A debounced function cannot return
fn’s result, becausefnruns later. If the caller needs the value, pass a callback or havefnresolve a promise. Lodash’s version returns the result of the previous invocation, which surprises people.
Try it yourself: debounce challenge.
Throttle, three ways
Timestamp: leading edge only
function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}It runs immediately, then drops calls until wait has passed. Simple, but the final call is lost: in the typing timeline it runs at 0 ms (“r”) and 300 ms (“react”), and “reac” never arrives. For scroll handlers that means the UI may never see the final position.
Timer: trailing edge only
function throttle(fn, wait) {
let timerId = null;
let lastArgs = null;
let lastThis = null;
return function (...args) {
lastArgs = args;
lastThis = this;
if (timerId !== null) return;
timerId = setTimeout(() => {
timerId = null;
fn.apply(lastThis, lastArgs);
}, wait);
};
}It runs at the end of each window with the latest arguments (100 ms with “reac”, 400 ms with “react”), so the final value always lands, but even the first call waits up to wait ms.
Both edges: what you usually want
function throttle(fn, wait) {
let last = 0;
let timerId = null;
let pendingArgs = null;
let pendingThis = null;
function throttled(...args) {
const remaining = wait - (Date.now() - last);
if (remaining <= 0) {
clearTimeout(timerId);
timerId = null;
last = Date.now();
fn.apply(this, args);
} else {
pendingArgs = args;
pendingThis = this;
timerId ??= setTimeout(() => {
last = Date.now();
timerId = null;
fn.apply(pendingThis, pendingArgs);
}, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timerId);
timerId = null;
last = 0;
};
return throttled;
}remaining is the time until the window reopens. If it is open, run now. Otherwise store the latest arguments and schedule a single trailing call for exactly when the window reopens; ??= guarantees only one timer is pending. The trailing call updates last, so the next window is measured from when fn really ran. The clearTimeout in the first branch covers a timer that fires late, since setTimeout delays are minimums, not promises.
Note
Date.now()follows the system clock, which can jump when it syncs or the user changes it.performance.now()is monotonic. Either is fine in an interview, but naming the difference is a nice senior touch. Also worth knowing: Lodash implementsthrottleas adebouncewith amaxWaitequal towait.
Try it yourself: throttle challenge.
Common bugs
- A new wrapper on every call or render. Each debounced function owns its own timer, so creating one inside a React component body means nothing is ever debounced. Create it once and clean up:
// Bug: a new debounced function, with a new timer, on every render
const onChange = debounce((e) => search(e.target.value), 300);
// Fix: one instance for the component's lifetime
const onChange = useMemo(() => debounce((e) => search(e.target.value), 300), []);
useEffect(() => () => onChange.cancel(), [onChange]);- Returning an arrow function.
return (...args) => { ... }makesthiswhatever it was whendebounceitself ran, usuallyundefined, so methods break. - Forgetting
clearTimeout. Every call still runs, justwaitms later. - Stale arguments. A trailing call that uses the first call’s arguments reports an old scroll position. Overwrite the stored arguments on every call.
- Calling instead of passing.
debounce(save(), 500)passessave’s return value. Passsave. - No cleanup. A pending trailing call can fire after unmount or navigation. Expose
cancel()and use it. - Wrong tool. Debouncing a scroll-driven UI shows nothing until the user stops scrolling. That is a throttle job.
Testing with fake timers
Real waits make tests slow and flaky. Fake timers move the clock deterministically. With Node’s built-in test runner:
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
test('runs once, after the pause, with the latest args', () => {
mock.timers.enable({ apis: ['setTimeout'] });
const spy = mock.fn();
const search = debounce(spy, 300);
search('r');
search('re');
search('rea');
mock.timers.tick(299);
assert.equal(spy.mock.callCount(), 0);
mock.timers.tick(1);
assert.equal(spy.mock.callCount(), 1);
assert.deepEqual(spy.mock.calls[0].arguments, ['rea']);
mock.timers.reset();
});Jest uses jest.useFakeTimers() and jest.advanceTimersByTime(300); Vitest uses vi.useFakeTimers() and vi.advanceTimersByTime(300). Check the boundary (299 vs 300 ms), this forwarding, cancel(), and each leading/trailing combination. Throttle also reads Date.now(), so the clock must be faked too: in Node add 'Date' to apis; Jest and Vitest fake Date by default.
The interview answer
“Both limit how often a function runs during a burst of calls. Debounce waits until the calls stop for wait milliseconds and then runs once with the latest arguments, which is right for search-as-you-type. Throttle runs at most once per wait, which suits scroll and resize handlers that need regular updates while the burst is still going.
Both are closures over a timer. I return a regular function so I can forward this and the arguments with apply, I keep the latest arguments for the trailing call, and I expose cancel() so a component can clean up on unmount. For throttle, I track when it last ran and schedule one trailing call for the remaining time, so the final event is never dropped. And I’d test it with fake timers rather than real waits.”