Debounce vs Throttle: Rate-Limiting Events in JS
Debounce and throttle both tame rapid-fire events, but differently — debounce waits for a pause, throttle enforces a steady rate. When to use each.
Debounce and throttle are two techniques for controlling how often a function runs in response to rapid-fire events like scrolling, typing, or resizing. Both exist to solve the same problem — an event that fires dozens or hundreds of times a second, triggering expensive work each time — but they behave differently. Debounce waits until the activity stops before running; throttle runs at a steady, capped rate during the activity. Choosing the wrong one produces sluggish or janky interfaces, so it’s worth understanding exactly how each behaves.
Why rate-limiting is necessary
Some browser events fire astonishingly often. A scroll or mousemove handler can be invoked hundreds of times per second. A keyup handler in a search box fires on every keystroke. If each firing does real work — recalculating layout, filtering a list, or hitting the network — you’ll do that work far more often than the user could possibly perceive, and the main thread grinds.
Because JavaScript runs on a single thread, every one of those handler calls has to complete before the browser can paint the next frame. Flood the thread and you get dropped frames and input lag — the exact scenario the JavaScript event loop explainer describes when the call stack never gets a chance to clear. Debounce and throttle both cut the number of calls dramatically; they just choose which calls to keep differently.
Debounce: wait for the pause
Debouncing delays a function until a set amount of time has passed without the event firing again. Every new event resets the timer. The function only runs once the activity settles down.
The canonical example is a search-as-you-type box. You don’t want to fire a network request on every keystroke — as the user types “keyboard” you’d fire eight requests, seven of them instantly stale. Debounce it with a 300ms delay, and the request fires only after the user pauses typing:
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
searchInput.addEventListener("input", debounce(runSearch, 300));
Each keystroke calls clearTimeout, cancelling the pending run, then schedules a new one. Only when 300ms elapse with no new keystroke does runSearch actually fire. Eight keystrokes produce one call.
Use debounce when you only care about the final state: search input, resizing a window and recalculating a layout when the user lets go, validating a field after the user stops typing, or saving a draft once edits pause.
Throttle: cap the rate
Throttling guarantees a function runs at most once per fixed interval, no matter how many times the event fires. Instead of waiting for a pause, it lets calls through at a steady cadence and ignores the rest.
The classic use is a scroll handler that updates a progress bar or triggers lazy-loading. You want updates while the user scrolls, not just when they stop — but you don’t need hundreds per second. Throttle to once every 100ms and you get smooth, frequent-enough updates at a fraction of the cost:
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));
The first call runs immediately and sets a cooldown. Any calls during the cooldown are dropped. When the interval elapses, the next call is allowed through. The function fires at a predictable, capped rate throughout the activity.
Use throttle when you need regular updates during a continuous event: scroll position, drag-and-drop, mousemove tracking, or firing analytics at a steady rate.
Side by side
| Debounce | Throttle | |
|---|---|---|
| When it runs | After activity stops | At a steady rate during activity |
| Reacts to | The final event | Events at fixed intervals |
| Every new event | Resets the timer | Ignored until cooldown ends |
| Best for | Search input, resize-end, autosave | Scroll, drag, mousemove, live tracking |
| Mental model | ”Wait until they’re done" | "At most once every N ms” |
The one-line distinction: debounce collapses a burst into a single trailing call; throttle spaces a burst out into evenly-timed calls.
Implementation notes
Both patterns rely on setTimeout, and both are stateful — each debounced or throttled function keeps its own timer in a closure. That means you should create the wrapped function once and reuse it, not recreate it on every render. In component-based UIs, that’s the difference between the timer surviving between renders and being reset every time; it’s the same “stable identity” concern that makes signals and other reactive state primitives so useful.
Real-world implementations add options — a leading call that fires immediately, a trailing call that guarantees the final event isn’t lost, and a cancel method to clear a pending run on unmount. Rather than hand-rolling every edge case, most projects use a well-tested utility, but knowing the mechanics means you can reach for the right one and debug it when the timing feels off. And remember these are tools of last resort for handler frequency — some cases are better solved with the browser’s own primitives, like using IntersectionObserver instead of a throttled scroll handler, or requestAnimationFrame for visual updates tied to the paint cycle, both of which cooperate with the browser rather than fighting it. Reducing wasted main-thread work this way is exactly the kind of tuning that moves the responsiveness metrics in our Core Web Vitals guide.
The takeaway
Debounce and throttle both rate-limit events, but they answer different questions. Debounce asks “has the activity stopped?” and runs once at the end — ideal for search boxes, resize handlers, and autosave. Throttle asks “has enough time passed?” and runs at a steady cadence throughout — ideal for scroll, drag, and live tracking. Pick debounce when only the final state matters and throttle when you need regular updates along the way, create the wrapped function once so its timer persists, and reach for IntersectionObserver or requestAnimationFrame when the platform already offers a better tool.
Tagged
Keep reading
Takina · · 4 min read What Is requestAnimationFrame? Smooth JS Animation
requestAnimationFrame schedules a callback right before the browser repaints, syncing JavaScript animation to the display's refresh rate. How it works.
Takina · · 3 min read React Suspense, Explained
React Suspense lets components pause rendering while they wait on async data, showing a fallback UI instead of manual loading-state juggling.
Takina · · 4 min read What Is the Beacon API? navigator.sendBeacon()
The Beacon API lets a page send one last async request as it unloads, without blocking navigation or racing the browser's page teardown.