A search box that fires a request on every keystroke, a scroll handler that recalculates layout 60 times a second, a "Save" button that gets double-clicked: all three are the same problem, a handler called far more often than it should be. Debouncing and throttling both fix that, but they fix it differently, and picking the wrong one gives you a search box that never searches or a progress bar that freezes. This takes about four minutes, and every number below comes from a run in Node 22.
The one-sentence difference
- Debounce: wait until the events stop, then run once.
- Throttle: run at most once per interval, however many events arrive.
To compare them, I fired 40 events 25 ms apart (about one second of "typing") at three versions of the same function, with a 300 ms wait:
| Strategy | Calls | When they ran | Arguments received |
|---|---|---|---|
| Nothing | 40 | every event | 1 to 40 |
| Debounce, 300 ms | 1 | about 300 ms after the last event (~1.3 s) | 40 |
| Debounce, leading edge | 1 | immediately (0 ms) | 1 |
| Throttle, 300 ms | 5 | 0, 300, 600, 900, 1200 ms | 1, 12, 24, 36, 40 |
Debounce gives you the final state once things settle. Throttle gives you a steady stream of updates while things are still happening, and still delivers the last value at the end.
Minimal implementations
You can use a library for this, but the logic is short enough to understand. These are the versions I tested:
export function debounce(fn, wait, { leading = false } = {}) {
let timer = null;
return function (...args) {
const callNow = leading && timer === null;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args);
}, wait);
if (callNow) fn.apply(this, args);
};
}
export function throttle(fn, wait) {
let last = 0;
let timer = null;
let lastArgs;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
lastArgs = args;
if (remaining <= 0) {
clearTimeout(timer);
timer = null;
last = now;
fn.apply(this, args);
} else if (timer === null) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, lastArgs);
}, remaining);
}
};
}
Note that the leading-edge debounce above runs only on the first call and ignores the rest of the burst. That suits a submit button; it does not give you the final value. Lodash's _.debounce offers both edges through its leading and trailing options (trailing defaults to true, leading to false).
Which one for which job
| Situation | Use | Why |
|---|---|---|
| Search-as-you-type, autosave, form validation on input | Debounce | Only the finished value matters |
| Window resize that recalculates layout | Debounce | Run once when the user lets go |
| Scroll position, drag, mousemove, progress updates | Throttle | You need updates during the action |
| Submit or "Like" button against double clicks | Debounce, leading edge | Act instantly, ignore the repeats |
| Sending analytics or heartbeat events | Throttle | Cap the rate, keep a steady flow |
Four mistakes that bite
1. A debounce that never fires
Debounce waits for quiet. If events keep coming, it keeps waiting. I ran 120 events over three seconds with a 300 ms debounce: it made exactly one call, at about 3.3 seconds. The same stream through a throttle produced 12 calls, one every 300 ms. If you need a guarantee that the function runs at least every so often, lodash has a maxWait option on _.debounce ("the maximum time func is allowed to be delayed before it's invoked"). In the lodash source, _.throttle is literally debounce with maxWait set to wait. With lodash 4.17.21, 40 events at 25 ms gave the same result as my hand-written versions: debounce made 1 call with argument 40, throttle made 5 calls with arguments 1, 12, 24, 36 and 40.
2. Debouncing does not fix out-of-order responses
Debouncing reduces the number of requests. It does not stop an older, slower request from finishing after a newer one. I simulated a server where the query lar takes 400 ms and larav takes 50 ms, and sent lar first:
naive: responses arrived: larav -> lar | UI ends up showing: lar (wrong)
abort: lar:AbortError -> larav | UI ends up showing: larav (right)
The fix is to cancel the previous request when a new one starts:
let controller;
async function search(q) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
render(await res.json());
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
input.addEventListener('input', debounce((e) => search(e.target.value), 300));
Use both: the debounce saves requests, and the abort guarantees the screen shows the latest answer.
3. Recreating the wrapper on every render
A debounced or throttled function keeps its timer in a closure. If you create a new one on each render (common in React components), each call gets a fresh timer and nothing is ever actually debounced. Create it once: at module level, or memoized so it survives re-renders. Also cancel any pending call when the component or page goes away; lodash's debounced functions expose cancel() and flush() for that.
4. Reaching for requestAnimationFrame to throttle scroll
MDN states that throttling the scroll handler with requestAnimationFrame() is useless, because animation frame callbacks fire at the same rate as scroll handlers, and recommends measuring a timeout yourself (its example uses setTimeout). If you only need to know when scrolling has finished, MDN points to the scrollend event instead of debouncing scroll. Check browser support for it before relying on it.
Choosing a wait time
There is no universal number, so treat these as starting points and tune by feel: a few hundred milliseconds for typing (the examples I found in the lodash docs use 150 ms for resize and 300 ms for a click handler), and roughly 100 to 250 ms for throttled scroll or mouse work. Too short and you barely reduce the load; too long and the interface feels broken.
Frequently asked questions
Is throttle just debounce with a maximum wait?
Effectively, yes. Lodash implements _.throttle by calling its debounce logic with maxWait equal to wait.
Do I need lodash for this?
No. The two functions above are enough for most cases. Use a library when you need cancel, flush, leading and trailing together, or maxWait without writing and testing them yourself.
Does debouncing replace server-side rate limiting?
No. It only reduces the load a well-behaved browser sends. Anyone can call your API directly, so limits still belong on the server.
References
Originally published on DEV Talk.
Top comments (0)