The Lab · Development
Debounce vs Throttle: I Counted Every Call
I fired 25 keystrokes and 60 scroll events at both in Node and counted the calls, then caught the stale-response bug neither one fixes.
Each line jumps to its section
- Debounce waits for a pause; 25 fast keystrokes collapsed into exactly 1 call at 300 ms
- Throttle keeps a steady beat; 60 scroll events at 60 Hz became 11 calls at 100 ms
- Debounce alone let a slow, stale search response overwrite the newer one in my test
- Pick by the question the UI asks: "when they stop" means debounce, "while it happens" means throttle
Debounce and throttle both cut the number of times a handler runs, and that's where most explanations stop. I wanted numbers, so I wrote both functions, fired fake keystrokes and scroll events at them in Node, and counted every call that came out the other side.
The counts are below. So is the bug that neither of them fixes.
What Each One Actually Does to Your Events
Debounce waits. Every new event resets a timer, and the handler only runs once the events stop for the full wait time. Throttle doesn't wait for a pause. It lets the handler run at most once per window, no matter how many events arrive inside it.
Here are the two versions I tested. Both are small enough to paste into any project without a dependency.
function debounce(fn, wait) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
}
function throttle(fn, wait) {
let last = 0, t = null, pending;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
pending = args;
if (remaining <= 0) {
clearTimeout(t); t = null; last = now;
fn.apply(this, args);
} else if (!t) {
t = setTimeout(() => {
last = Date.now(); t = null;
fn.apply(this, pending);
}, remaining);
}
};
}
The throttle has one detail people often leave out: the trailing call. Without it, the last events inside a window get dropped, and a scroll position or slider value can freeze a few pixels short of where the user actually stopped. With it, the final event always lands. In my test the throttled handler received event 59 of 59, the true last one.
The opposite edge matters too. My throttle fires on the first event right away (the leading edge), so a drag handle reacts the instant you grab it. My debounce only fires on the trailing edge, after the quiet period. That's right for search and wrong for a button that should respond to the first click, which is why some libraries offer a leading: true option on debounce as well. If a control feels laggy on the very first touch, check which edge it's firing on before you touch the timing.
If you already use lodash, its _.throttle is built on _.debounce with a maxWait option, so the two are closer relatives than their names suggest. You don't need lodash for either, though.
The Numbers: 25 Keystrokes and 60 Scroll Events
Test one simulated someone typing "debouncing and throttling", which is 25 characters, at one key every 80 ms. That's a fast but realistic typist.
- Debounce at 300 ms: 1 call, after the last key.
- The same typing with one 450 ms pause halfway through: 2 calls.
- Throttle at 300 ms: 8 calls, spread across the whole burst.
Test two simulated one second of scrolling at 60 Hz: 60 events, 16 ms apart.
- Throttle at 100 ms: 11 calls.
- Debounce at 100 ms: 1 call, after the scrolling stopped.
Those two tests show the whole difference better than any definition. Debounce turned a burst into one final answer. Throttle turned the same burst into a steady beat of intermediate answers.
Why 300 ms for the search box? Fast typists leave something like 80 to 200 ms between keys, so any wait above that collapses a word into one call. 300 sits safely above the typing gap and still feels instant once they stop. The 450 ms test shows the other side of that choice: a real pause mid-word counts as "stopped", which is usually what you want, because the user is probably reading the suggestions.
Neither is better. They answer different questions, and using the wrong one feels broken in a way users notice immediately. A search box on throttle fires requests for "debo", "debounc" and so on, most of them useless. A scroll-linked progress bar on debounce sits frozen for the whole scroll, then jumps once at the end.
Scroll and input are exactly where this shows up in Core Web Vitals. INP, Interaction to Next Paint, has been a Core Web Vital since March 2024, and 200 ms or less counts as good. A heavy handler running on every keystroke is one of the easiest ways to blow past that. I looked at what moved INP on a real store in Six Months of Shopify Web Vitals Data What Actually Moved INP.
The Bug Neither of Them Fixes
This is the part that bites people who did everything "right".
Say the search box is debounced at 300 ms. The user types "deb", pauses for a moment, then finishes "debounce". Two debounced calls fire, 320 ms apart. The short query is the slow one, because it matches more results: 450 ms for "deb", 120 ms for "debounce".
So the responses come back in the wrong order. "debounce" lands first, and then "deb" lands 10 ms later and overwrites it.
In my simulation the UI ended up showing results for "deb" while the input said "debounce". Debounce did its job perfectly. It cut two dozen keystrokes down to two requests. It just has no opinion on the order those requests come back in.
The fix is one AbortController per query:
let controller;
const onSearch = debounce(async (q) => {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
render(await res.json());
} catch (e) {
if (e.name !== "AbortError") throw e;
}
}, 300);
Rerun with the abort in place, the same test showed results for "debounce". The older request is cancelled before its response can land, so the order stops mattering. Two things to keep: swallow only AbortError, so real network failures still surface, and create a new controller per call, because an aborted controller stays aborted.
One more leak to close in component code. A debounced call that's still waiting when the component unmounts will fire anyway and try to update something that no longer exists. Give the debounce a cancel and call it in your cleanup:
function debounce(fn, wait) {
let t;
const d = function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
d.cancel = () => clearTimeout(t);
return d;
}
In React that's useEffect(() => () => onSearch.cancel(), []), plus aborting the controller in the same cleanup.
Where Each One Belongs
This is the map I'd use. It's organised by the question the interface is asking, because that question decides it faster than any rule about event types.
"Tell me when they stop" is debounce:
- Search-as-you-type and autocomplete, at 250 to 300 ms, always with the abort above.
- Form validation that calls a server, like "is this username free".
- Autosave, with one addition. A pure debounce never saves while someone types without pausing, so add a
maxWait(lodash has it) and save at least every few seconds anyway. - Expensive work after a window resize. For layout that reacts to one element's size,
ResizeObserveris usually the better tool.
"Keep me updated while it happens" is throttle:
- Scroll position for sticky headers, progress bars or analytics.
- Drag handles, sliders and anything following the pointer.
- Rate-limiting your own API calls from a chatty UI.
For anything that paints, reach for requestAnimationFrame before either. It runs once per frame, which is exactly the rate the screen can show, and it pauses in background tabs for free.
And one case where neither belongs: the double-submitted form. Throttling the click handler looks like a fix and isn't. A slow network or a second tab still gets two orders through. Disable the button on the first click, and make the server treat a repeated request as the same request. That's the server's half of the job, and I covered the storage side of it in Rate Limiting Without Redis: 3 Patterns I Use in Serverless.
Testing all of this doesn't need real waiting. Vitest and Jest both ship fake timers: switch them on, fire your events, advance the clock by the wait time and assert on the call count. That's the same test I ran in Node, minus the real 80 ms sleeps, so it runs in milliseconds and never flakes on a slow CI machine.
Bottom Line
If you remember one test from this, make it the search box. Debounce it at 300 ms, abort the previous request every time, and you've fixed the two problems that make search feel broken: the flood of requests and the stale result.
Everything else falls out of the question the UI is asking. "When they stop" means debounce. "While it happens" means throttle, or requestAnimationFrame if it's drawing something. And if the real problem is a duplicate action, neither timer fixes it, the button and the server do.
The two functions above are dependency-free and tested. Copy them. If you'd rather read more of these build notes by topic, the Lab overview groups every development article in one place.
Which one did you last reach for out of habit when the other one fit better?