Debounce vs Throttle in JavaScript, With a Timeline Demo
Some browser events fire far more often than your code needs. A user typing a search term triggers input on every keystroke, and scrolling or resizing can fire dozens of events a second. If each one sends a request or recalculates a layout, the page becomes slow and your server gets far too many requests. Debounce and throttle are the two standard ways to limit how often a function runs. They are small enough to write yourself.
They are often confused, so here is the short version before the code:
- Debounce: wait until the events stop for a given time, then run once. Ten keystrokes in a row produce one call.
- Throttle: run at most once per given interval while the events keep coming. Scrolling for one second with a 200 ms throttle produces about five calls.
All code below runs in plain Node.js (I used 18.20.4) as well as in the browser, because it only needs setTimeout.
A debounce function
The idea is one timer that is restarted on every call. Only when nobody calls for wait milliseconds does the timer get to fire:
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null;
let lastArgs = null;
let lastThis = null;
function debounced(...args) {
const callNow = leading && timer === null;
lastArgs = args;
lastThis = this;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (trailing && lastArgs) fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}, wait);
if (callNow) {
fn.apply(this, args);
lastArgs = lastThis = null; // already handled on the leading edge
}
}
debounced.cancel = () => {
clearTimeout(timer);
timer = lastArgs = lastThis = null;
};
return debounced;
}A few details matter. The wrapper remembers the latest arguments, so the final call sees the final value of the search box, not the first one. It keeps this, so it works as an object method or a DOM handler that uses this. And timer === null tells us whether we are at the start of a burst, which is what the leading option needs.
A throttle function
Throttle remembers when the function last ran. If enough time has passed, it runs immediately. If not, it schedules one trailing call for the moment the interval is over, so the final position after a scroll is never lost:
function throttle(fn, wait, { trailing = true } = {}) {
let last = 0;
let timer = null;
let lastArgs = null;
let lastThis = null;
function run() {
last = Date.now();
timer = null;
fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}
function throttled(...args) {
lastArgs = args;
lastThis = this;
const remaining = wait - (Date.now() - last);
if (remaining <= 0) {
clearTimeout(timer);
run();
} else if (trailing && timer === null) {
timer = setTimeout(run, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timer);
timer = lastArgs = lastThis = null;
last = 0;
};
return throttled;
}Because last starts at 0, the first call always runs immediately. While the interval is running, extra calls only update lastArgs. The pending timer picks up whatever the newest arguments are when it fires.
A timeline you can run
Reading about timing is less convincing than watching it, so I wrote a small simulation. It types "linux" one key every 100 ms, pauses, types " cli", and then fires a scroll event every 50 ms for one second. Save both functions above in lib.js with module.exports = { debounce, throttle }; at the end, then:
const { debounce, throttle } = require('./lib');
const start = Date.now();
const t = () => String(Date.now() - start).padStart(4) + 'ms';
const search = debounce((q) => console.log(`${t()} debounced -> ${q}`), 300);
const onScroll = throttle((y) => console.log(`${t()} throttled -> ${y}`), 200);
// typing "linux" one key every 100 ms, a pause, then " cli"
const keys = [
[0, 'l'], [100, 'li'], [200, 'lin'], [300, 'linu'], [400, 'linux'],
[1000, 'linux '], [1100, 'linux c'], [1200, 'linux cl'],
[1300, 'linux cli'],
];
for (const [at, q] of keys) {
setTimeout(() => {
console.log(`${t()} key: "${q}"`);
search(q);
}, at);
}
// a scroll event every 50 ms for 1 second, starting at 2000 ms
for (let i = 0; i <= 20; i++) {
setTimeout(() => onScroll(i * 40), 2000 + i * 50);
}This is the output from one run on my machine. Timer callbacks in Node are never exactly on time, so your numbers will differ by a few milliseconds:
$ node timeline.js
2ms key: "l"
100ms key: "li"
200ms key: "lin"
300ms key: "linu"
400ms key: "linux"
706ms debounced -> linux
1001ms key: "linux "
1101ms key: "linux c"
1200ms key: "linux cl"
1300ms key: "linux cli"
1601ms debounced -> linux cli
2001ms throttled -> 0
2201ms throttled -> 160
2402ms throttled -> 320
2603ms throttled -> 480
2803ms throttled -> 640
3003ms throttled -> 800Nine keystrokes became two debounced calls. Each came about 300 ms after the last key of its burst, and each had the complete text. For a search-as-you-type box that means two requests instead of nine, and no requests for half-typed words like "lin".
Twenty-one scroll events became six throttled calls, one roughly every 200 ms. The first ran immediately, and the last one carries the final position (800) because of the trailing call. A debounced scroll handler, by contrast, would not have run at all until scrolling stopped. That is the wrong behaviour for something like a "back to top" button that should appear while you scroll.
Leading and trailing edges
The "edge" is which end of a burst triggers the call. Debounce defaults to the trailing edge: wait for quiet, then run. Sometimes you want the opposite: run on the first event and ignore the rest of the burst. A common case is a Save or Submit button that impatient users double-click:
const { debounce } = require('./lib');
const start = Date.now();
const t = () => String(Date.now() - start).padStart(4) + 'ms';
const save = debounce(
(n) => console.log(`${t()} save() for click ${n}`),
300,
{ leading: true, trailing: false }
);
// an impatient double (triple) click, then one more click later
[0, 80, 160, 700].forEach((at, i) => {
setTimeout(() => {
console.log(`${t()} click ${i + 1}`);
save(i + 1);
}, at);
});$ node leading.js
2ms click 1
5ms save() for click 1
81ms click 2
162ms click 3
701ms click 4
701ms save() for click 4The user gets an instant response, clicks 2 and 3 are ignored, and a click after the quiet period works again. With both edges enabled, you get a call at the start of a burst and one at the end. But a single isolated call only runs once. This is why the code clears lastArgs after a leading call. This and the next example start with the same require and t() lines as leading.js:
const f = debounce((n) => console.log(`${t()} call ${n}`), 300,
{ leading: true, trailing: true });
[0, 80, 160].forEach((at, i) => setTimeout(() => f(i + 1), at));
setTimeout(() => f(4), 1000); // a single call: leading edge only$ node both.js
2ms call 1
461ms call 3
1001ms call 4The trailing call came 300 ms after the last call of the burst (at 160 ms), with that call's argument.
cancel(): dropping a pending call
A debounced or throttled function may have a call waiting in a timer. Sometimes that call must not happen: the form was submitted, the component was removed from the page, or the user navigated away. That is what cancel() is for:
const autosave = debounce(() => console.log(`${t()} autosave`), 500);
autosave();
console.log(`${t()} edit, autosave scheduled`);
setTimeout(() => {
autosave.cancel();
console.log(`${t()} form submitted, pending autosave cancelled`);
}, 200);
setTimeout(() => console.log(`${t()} done, nothing else ran`), 1000);$ node cancel.js
1ms edit, autosave scheduled
208ms form submitted, pending autosave cancelled
1010ms done, nothing else ranWithout the cancel, autosave would have fired at around 500 ms and written stale data after the real submit. In a single-page app, call cancel() in your cleanup code whenever you remove a handler that was debounced. If you do not, the timer can still fire against a component that no longer exists.
Which one to use
| Situation | Use |
|---|---|
| Search box, autocomplete, validation while typing | debounce (trailing), 250-400 ms |
| Autosave of a text field | debounce (trailing), with cancel() on submit |
| Save button, preventing double submits | debounce (leading only) |
| Window resize, recalculating a layout | debounce if only the final size matters, throttle if the layout must follow along |
| Scroll position, infinite scroll, sticky headers | throttle, 100-200 ms |
| Mouse move or drag tracking, rate-limited API calls | throttle |
The question I ask is: does the user care about the intermediate states? If only the final value matters (the finished search term), debounce. If they should see things update while the action continues (a progress indicator during scrolling), throttle.
For animation work tied to the screen, such as moving an element with the mouse, requestAnimationFrame is often a better throttle than a fixed interval. It runs once for each frame the browser actually paints. Libraries such as Lodash provide debounce and throttle with more options (maxWait, flush()). If you already depend on one, use it. For a small site, the twenty or so lines above are all you need.