Some events fire far more often than you want. A user typing in a search box triggers keyup on every keystroke; scrolling fires dozens of times a second; resizing a window is a firehose. If you run expensive work on every one of those events, your app janks. Debounce and throttle are the two techniques that tame this, and knowing which to use where is a genuinely useful skill.
Debounce: wait for the pause
Debouncing says: “do the work only after the events stop coming for a moment.” It is perfect for a search-as-you-type box — you do not want to hit the API on every letter, only once the user pauses.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
searchInput.addEventListener('input',
debounce(runSearch, 300));
Every keystroke resets the timer. The search only runs 300ms after the last one. Type “javascript” quickly and you make one request instead of ten.
Throttle: at most once per interval
Throttling says: “run at most once every N milliseconds, no matter how many events arrive.” It is the right tool for scroll and resize handlers, where you want steady updates but not hundreds per second.
function throttle(fn, interval) {
let ready = true;
return (...args) => {
if (!ready) return;
ready = false;
fn(...args);
setTimeout(() => { ready = true; }, interval);
};
}
window.addEventListener('scroll',
throttle(updateProgressBar, 100));
Now your scroll handler fires a maximum of ten times a second, which is smooth enough for a progress bar while sparing the browser.
The difference in one sentence
Debounce waits until the activity stops; throttle enforces a steady maximum rate during the activity. Search input wants the pause, so debounce. Scroll tracking wants regular sampling, so throttle. Keep that distinction and you will pick correctly every time.
Do not reinvent it in production
The versions above are great for understanding the idea, and honestly fine for small projects. But battle-tested implementations handle edge cases like leading/trailing calls and cancellation. Libraries such as Lodash ship debounce and throttle that have handled those corners for years, so reach for them in real apps.
Leading vs trailing: the option that changes behavior
Once you use these in real interfaces, a subtlety appears: when exactly should the function fire? The debounce above is trailing — it fires after the pause. But sometimes you want leading — fire immediately on the first event, then go quiet. A “Save” button that users might double-click wants leading debounce: the first click saves instantly, the accidental second click within the window is swallowed. A search box wants trailing: nothing should happen until typing pauses.
Throttle has the same nuance. A trailing throttle guarantees you catch the final event — crucial for a scroll handler that must know where scrolling ended, not just sample positions along the way. Lodash’s implementations expose both as options ({ leading: true, trailing: false }), and knowing which combination you need is usually the difference between “works” and “feels slightly wrong in a way users can’t articulate.”
The cleanup bug that bites React developers
A modern gotcha worth knowing: debounced functions hold a pending timer, and if the component that created them unmounts — or the page navigates — that timer still fires. In React, this is the classic “setState on unmounted component” warning, or worse, a search result arriving for a component that no longer exists. The fix is cancellation on cleanup:
useEffect(() => {
const debouncedSearch = debounce(runSearch, 300);
input.addEventListener('input', debouncedSearch);
return () => {
debouncedSearch.cancel(); // lodash debounce has .cancel()
input.removeEventListener('input', debouncedSearch);
};
}, []);
This is also a hidden reason to prefer library implementations: hand-rolled versions rarely include .cancel() and .flush(), and you will eventually need both. A related trap: recreating the debounced function on every render resets its internal timer, silently breaking the debounce entirely — memoize it (useMemo) so one instance survives across renders.
Picking the delay: numbers that work in practice
The delay value is a UX decision disguised as a technical one, and reasonable defaults exist. For search-as-you-type, 250–350ms tracks the natural pause between typed words — shorter feels twitchy and wastes requests, longer feels laggy. For window resize handlers, 100–200ms of debounce is plenty; layouts recalculating mid-drag are wasted work. For scroll-driven UI (progress bars, reveal animations), a 50–100ms throttle keeps things visually smooth — and for purely visual scroll work, consider requestAnimationFrame-based throttling instead, which naturally syncs to the browser’s paint cycle at ~16ms.
Also know when you need neither: for reacting to element visibility, IntersectionObserver replaces scroll-handler-plus-throttle entirely, and it’s both cleaner and faster. The best event handler is the one the browser runs for you.
Seeing the difference: a mental timeline
If the distinction still feels slippery, walk through ten rapid events one second apart from a burst of user activity. With a 300ms debounce, events 1 through 9 each reset the timer and nothing runs; only after event 10, once 300ms of silence passes, does the function fire — once, at the end. With a 300ms throttle, the function fires at event 1, ignores events for 300ms, fires again around event 4, again around event 7 — steady pulses throughout the burst. Debounce collapsed the burst to one call at the end; throttle sampled it at a fixed rhythm. Sketch that timeline once on paper and you’ll never confuse them again — and you’ll immediately see why autocomplete wants debounce (only the final query matters) while a game’s position updates want throttle (you need readings during the motion, not after it).
Frequently asked questions
Should I debounce the API call or the input handler? Debounce as close to the expensive operation as possible. Cheap UI updates (character counters) can run on every keystroke while the network request behind them (usually an async/await call) is debounced — users get instant feedback and your server gets one request.
Do I still need these with fast devices? Yes — the cost you’re managing is often network requests, API rate limits, and server load, none of which improve with a faster phone. Debouncing a search box is as much about not hammering your backend as about UI smoothness.
What about debouncing in CSS or the platform? Some cases have native solutions now: scroll-timeline animations, IntersectionObserver, and the search event on inputs cover scenarios that once required manual throttling. Check whether the platform already solved your case before writing timer code.
Master debounce and throttle and a whole class of performance problems simply disappears. They are small functions with an outsized impact on how smooth your interface feels.

