A search box that queries the server on every keystroke, a scroll handler that recomputes a position on every pixel: these patterns trigger hundreds of needless calls and hurt perceived responsiveness. Debouncing and throttling are the two classic answers. They look alike, but they solve different problems, and mixing them up yields interfaces that are either too sluggish or too jumpy.
One symptom, two strategies
The shared premise: an event repeats far faster than the work you want to attach to it. The difference is the moment chosen to act. Debouncing waits for a lull before firing the work once. Throttling lets one call through at a regular interval and drops the rest. The first optimises for “only the last value matters”, the second for “a controlled rhythm is enough”.
Debouncing: act only once things go quiet
A debounce resets a timer on every call and runs the function only if no new call arrived during the delay. That is the expected behaviour of autocomplete: the request fires only when the user stops typing.
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const search = debounce((query) => {
fetch(`/api/search?q=${encodeURIComponent(query)}`);
}, 300);
input.addEventListener('input', (e) => search(e.target.value));Tuning the delay is a trade-off: too short and the wait is pointless; too long and the interface feels dead. Between 200 and 400 milliseconds covers most input fields. These utilities benefit from being typed so the wrapped function keeps its signature.
Throttling: guarantee a steady rhythm
A throttle runs the function immediately, then blocks any new call for a given window. It suits continuous streams where intermediate values matter: scrolling, mouse movement, resizing.
function throttle(fn, interval = 200) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => {
updateScrollProgress(window.scrollY);
}, 200);
window.addEventListener('scroll', onScroll, { passive: true });For purely visual work, requestAnimationFrame offers a natural throttle aligned with the screen refresh rate, avoiding recalculation more often than the browser paints.
| Criterion | Debouncing | Throttling |
|---|---|---|
| Moment of execution | After a lull | At a regular interval |
| Number of calls | One, at the end | One per time window |
| Value that matters | The last one | Intermediate ones too |
| Typical case | Autocomplete, form validation | Scroll, resize, drag |
Debounce waits for the end; throttle sets a tempo. Picking one for the other means solving the wrong problem.
Common pitfalls and good practice
The regulated function must be created once, not on every render: inside a component, a fresh instance each cycle resets the timer and cancels the whole effect. You should also remove the listener on unmount and, for a debounce, provide a cancel method when the context disappears, for instance when a field closes.
Choice cue. If only the final value matters, a debounce is enough. If you need continuous but bounded feedback, a throttle. And if the computation only feeds the display, requestAnimationFrame often makes both unnecessary.
Finally, understand the implementation before depending on a library. The lodash versions handle fine-grained options, such as firing at both the leading and trailing edge of the window, but a handful of home-grown lines cover most needs and spare a dependency for behaviour you control.
Key takeaways
Debouncing and throttling are not opposites, they complement each other. The first collapses a burst of events into a final action; the second smooths a continuous stream into a steady rhythm. The right reflex is to name the need first — last value or bounded cadence — then pick the technique, not the other way round. For display-only work, animation-frame scheduling remains the most economical tool.
On my own projects, nine regulations out of ten come down to a dozen home-grown lines, no dependency. I only reach for the library when I need both leading and trailing edges at once, and then there is no point rewriting it. The real trap, the one that cost me the most debugging time, is still the function recreated on every render. — Simon Janvier
Further reading: the MDN reference on requestAnimationFrame.
