Debounce & throttle from scratch
- Write a trailing-edge
debouncethat forwardsthisand the latest arguments, with workingcancel()andflush() - Write a leading-and-trailing
throttlewhose trailing run uses the latest call, not the call that started the timer - Predict which calls run, and when, under trailing, leading, leading-plus-trailing and
maxWaitsettings - Explain why continuous input needs
maxWait, why throttle is a debounce withmaxWaitequal towait, and why a fresh wrapper per call debounces nothing
Read
A debounce is a timer you keep resetting
FlagA debounced function waits for the calls to stop. Each call cancels the timer the previous
call started and starts a new one, so fn runs once, wait ms after the last call, with
that call's arguments.
debounce is a function that takes your function and returns a new one. The timer handle
lives in the closure, shared by every call to the returned function:
function debounce(fn, wait) {
let timer; // shared by every call to the function we return
return (...args) => {
clearTimeout(timer); // cancel the previous call's timer
timer = setTimeout(() => fn(...args), wait); // and start the clock again
};
}
const search = debounce((q) => console.log("search", q), 300);
search("r");
search("re");
search("rea"); // ...then 300 ms of silence
// search rea
Three calls, one run, with the last arguments. "r" and "re" were scheduled, then
cancelled before their 300 ms were up.
That's the trailing edge: the run happens at the end of the burst. It's what you want when
only the final value matters (search-as-you-type, autosave, "resize finished"). Its weakness is
the flip side of its strength: if the calls never stop, fn never runs.
Two things this ten-line version gets wrong, which the medium tier fixes:
thisis lost. An arrow function has nothisof its own, andfn(...args)callsfnas a plain function. Attach the debounced function to an object as a method andfnwon't see that object.- There's no way to cancel or flush. A component being torn down needs to drop the pending
call (or run it now), and nothing outside the closure can reach
timer.
A complete debounce, line by line
FlagThe version you'd actually ship forwards this, always uses the latest call's arguments, and
exposes cancel() and flush():
export function debounce<F extends (...args: any[]) => void>(fn: F, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined; // set ⇔ a call is pending
let lastArgs: Parameters<F> | undefined;
let lastThis: ThisParameterType<F> | undefined;
function invoke() {
const args = lastArgs!;
const thisArg = lastThis;
timer = lastArgs = lastThis = undefined; // nothing is pending any more
fn.apply(thisArg, args);
}
function debounced(this: ThisParameterType<F>, ...args: Parameters<F>) {
lastArgs = args;
lastThis = this;
clearTimeout(timer);
timer = setTimeout(invoke, wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = lastArgs = lastThis = undefined;
};
debounced.flush = () => {
if (timer === undefined) return; // nothing pending: don't run anything
clearTimeout(timer);
invoke();
};
return debounced;
}
What each piece is for:
function debounced, not an arrow. Afunctiongetsthisfrom how it's called, soeditor.save = debounce(saveDraft, 500); editor.save()givessaveDraftthe editor. Savingthisalongside the arguments and callingfn.apply(thisArg, args)passes both through.lastArgsis overwritten on every call. The timer callback reads them when it fires, so the run always uses the final call's arguments. Closing over theargsof the call that created a timer is the classic way to get stale arguments (the throttle reading shows it).timerdoubles as the "pending" flag. It's set while a run is scheduled and cleared ininvoke. That one invariant is what makesflushcorrect. Forget to clear it when the timer fires, and a laterflush()runsfnagain with old arguments.cancelmust callclearTimeout. Settingtimer = undefinedalone forgets the handle, but the scheduled run still fires. Clearing the handle is what stops it.flushcancels the timer before running. Runfnwithout cancelling, and the timer fires againwaitms later: two runs for one burst.
When do you need them? cancel() on teardown: the screen is going away, so drop the pending
write. flush() when the user does something explicit: they pressed Save or closed the tab, so
run the pending call now rather than in 500 ms.
Leading edges, maxWait, and why throttle is a debounce
FlagDebounce and throttle are two settings of one machine. Lodash (4.x) exposes the knobs on
debounce(fn, wait, { leading, trailing, maxWait }), and its throttle is literally
debounce(fn, wait, { leading: true, trailing: true, maxWait: wait }).
The edges. For a burst of calls a b c with less than wait between them:
| Options | Runs |
|---|---|
| trailing only (debounce's default) | c, wait ms after the last call |
| leading only | a, immediately; nothing else until wait ms of silence |
| leading + trailing | a immediately, then c after the silence |
A single call with leading + trailing runs once, not twice. Lodash's docs: the trailing run
happens "only if the debounced function is invoked more than once during the wait timeout."
Leading-only debounce suits "act on the first click, ignore the double-click".
maxWait: a deadline for the burst. A trailing debounce has one failure mode: input that
never pauses. Someone typing steadily, or a sensor reporting every 50 ms against a 100 ms
debounce, gets no runs at all until it stops. maxWait adds a second timer that starts with
the burst and is not reset by later calls:
function debounce(fn, wait, { maxWait } = {}) {
let timer, maxTimer, lastArgs, lastThis;
function invoke() {
clearTimeout(timer);
clearTimeout(maxTimer);
timer = maxTimer = undefined;
const args = lastArgs, thisArg = lastThis;
lastArgs = lastThis = undefined;
fn.apply(thisArg, args);
}
return function debounced(...args) {
lastArgs = args;
lastThis = this;
clearTimeout(timer);
timer = setTimeout(invoke, wait); // reset on every call
if (maxWait !== undefined && maxTimer === undefined) {
maxTimer = setTimeout(invoke, maxWait); // NOT reset: the burst's deadline
}
};
}
Calling it every 10 ms for 300 ms with wait: 50, maxWait: 120 runs at about 120 ms, 240 ms,
and once more after the input stops. Without maxWait, it runs once, at about 350 ms. Lodash
produces the same pattern.
Now set maxWait equal to wait and add a leading edge. A run is due at least once per wait
while calls keep coming, with one at the start and one at the end. That's a throttle, which is
why lodash builds it this way.
Quiz
editor.save = debounce(saveDraft, 500), and saveDraft reads this.id. The debounce wraps with (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); }. What this does saveDraft see after editor.save()?
A debounce's cancel() is written as () => { timer = undefined; }. What happens to a pending run?
A throttle schedules its trailing run with setTimeout(() => fn.apply(this, args), remaining) inside the first call that finds the window closed. A burst reports scroll positions 0, 40, 80, 120. The trailing run reports 40. Why?
A sensor calls a 100 ms trailing debounce every 50 ms, without pause, for an hour. How often does fn run during that hour?
Lodash (4.x) implements throttle(fn, wait) as which debounce call?
A debounce's invoke runs fn.apply(lastThis, lastArgs) but never resets timer. Its flush is if (timer !== undefined) { clearTimeout(timer); invoke(); }. The user types, the save fires 500 ms later, then they click Save, which calls flush(). What happens?
In a leading-and-trailing throttle, why must the trailing run also record lastRun = Date.now()?
Fill in the blank
Save the caller's receiver along with the arguments, and pass both on when the timer fires.
Flagfunction debounced(...args) { lastArgs = args; lastThis = ; clearTimeout(timer); timer = setTimeout(invoke, wait); } function invoke() { const args = lastArgs, thisArg = lastThis; timer = lastArgs = lastThis = undefined; fn.(thisArg, args); }
Make cancel() actually stop the pending run.
debounced.cancel = () => { (timer); timer = lastArgs = lastThis = undefined; };
The throttle's trailing timer must deliver the latest call, not the call that created the timer. Which variable does it read?
Flag// on every call while the window is closed: pendingArgs = args; timer = setTimeout(() => { const latest = ; timer = pendingArgs = undefined; run(latest); }, remaining);
Build a throttle out of a lodash-style debounce.
Flagconst throttle = (fn, wait) => debounce(fn, wait, { leading: true, trailing: true, : wait });
Predict the output
import debounce from "lodash/debounce"; // lodash 4.x
const save = debounce((v) => console.log("save", v), 100, { leading: true, trailing: true });
save("a");
setTimeout(() => {
save("b");
save("c");
}, 300);const searches = [];
const search = (q) => searches.push(q);
// a debounce like the one in the readings, 30 ms
const onInput = (value) => debounce(search, 30)(value);
onInput("r");
onInput("re");
onInput("rea");
setTimeout(() => console.log(searches), 100);import debounce from "lodash/debounce"; // lodash 4.x
let runs = 0;
const onReading = debounce(() => { runs += 1; }, 50, { maxWait: 120 });
const start = Date.now();
const id = setInterval(() => {
onReading();
if (Date.now() - start > 300) clearInterval(id);
}, 10);
setTimeout(() => console.log("runs:", runs), 600);Challenge
Finish a hand-rolled debounce and throttle
Flagrate-limit.ts holds the two utilities a search box, an autosave and a scroll handler share:
debounce(fn, wait)runsfnonce,waitms after the last call, with that call'sthisand arguments. The returned function also hascancel()(drop the pending run) andflush()(run it now).throttle(fn, wait)runs the first call immediately, then at most once perwait. Calls that arrive while the window is closed collapse into one trailing run, with the latest call'sthisand arguments, so the last value always gets through. It hascancel()too.
Both pass a quick manual test, which is how they shipped. The six bugs only show up in how
they're actually used: a debounced method on an object, a component that cancels on teardown,
a Save button that flushes, a scroll handler whose final position matters. The comment above
debounce lists them in the order worth fixing.
WAIT_MS in config.ts is the delay the tests use.
Your task
Edit rate-limit.ts so the tests pass:
debouncerunsfnonce,waitms after the last call, with the last call's argumentsdebouncepassesfnthethisit was called withdebounced.cancel()drops a pending run for good: it never fires, and a laterflush()has nothing to rundebounced.flush()runs a pending call immediately and exactly once, and does nothing when nothing is pendingthrottleruns the first call immediately and never runs twice withinwaitthrottle's trailing run uses the latest call'sthisand arguments, and a single call runs only oncethrottled.cancel()drops the pending trailing run