Debounce, Throttle, and the Event-Loop Tax
Photo: Unsplash
Some browser events fire absurdly often. mousemove can emit hundreds of events a second.
scroll and resize fire on every frame the value changes. An input listener runs on
every keystroke. Attach an expensive handler — one that hits the network, recalculates
layout, or re-renders a big component — and you've signed up to run that work hundreds of
times a second on the main thread.
That's the event-loop tax: handlers queuing faster than they finish, the main thread saturated, the page janky and unresponsive. Debounce and throttle are the two classic tools for paying less of it. They sound similar and are constantly confused, but they solve different problems, and picking the wrong one is its own bug.
Why the main thread can't keep up
JavaScript runs on a single thread driven by an event loop.
Each event handler is a task; tasks run to completion one at a time. While your scroll
handler runs, nothing else can — not rendering, not other input, not the next scroll
event. If the handler takes 20ms and scroll events arrive every 4ms, you fall behind
instantly and the queue backs up.
The goal of debounce and throttle is the same: decouple how often an event fires from how often your handler runs. They just decouple it differently.
Debounce: wait until it stops
Debouncing says "do nothing until the events stop coming, then run once." Every new event resets a timer; the handler only fires after a quiet period. This is exactly right for "the user finished" moments — a search box that queries an API after typing pauses, a form field that validates once you stop editing, a window that recomputes layout after a resize settles.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Fires once, 300ms after the user stops typing.
input.addEventListener("input", debounce((e) => {
search(e.target.value);
}, 300));Without the debounce, typing "performance" fires eleven searches. With it, you fire one. The user can't tell the difference except that the page stopped lagging.
Throttle: run at a steady rate
Throttling says "run at most once every N milliseconds, no matter how many events arrive."
Unlike debounce, it fires during a continuous stream, just at a capped rate. This is the
right tool when you need ongoing feedback: updating a scroll progress bar, tracking
mousemove for a drag interaction, firing analytics on scroll depth.
function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn(...args);
}
};
}
// Runs at most every 100ms while scrolling, instead of every frame.
window.addEventListener("scroll", throttle(updateProgressBar, 100));The distinction in one line: debounce waits for a pause; throttle enforces a rhythm. A search box wants debounce (act when typing stops). A scroll indicator wants throttle (update steadily as you scroll).
Don't hand-roll these for production. Battle-tested implementations handle leading/trailing
edges, cancellation, and this binding correctly — edge cases the four-line versions above
skip. Reach for a small utility, but understand the mechanics so you choose the right one.
The better tool for scroll and resize
Here's the twist: for many scroll- and resize-driven UIs, you shouldn't use either. They both still run your handler on the main thread, just less often. Two modern browser APIs do the job without polling at all.
IntersectionObserver
tells you when an element enters or leaves the viewport — lazy-loading, infinite scroll,
"is this visible" — without a single scroll listener. ResizeObserver fires when an
element's size changes without a resize handler. They run off the main-thread hot path
and only when something actually changes. If your throttled scroll handler exists to detect
visibility, delete it and use an observer:
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadMore();
}
});
io.observe(sentinelElement);And when you genuinely need to react every frame — say, animating something to scroll
position — synchronize with the browser's paint cycle using
requestAnimationFrame
instead of throttling to a guessed interval. rAF runs at most once per frame, exactly
when the browser is about to paint, so you never compute a value that gets thrown away.
Choosing in practice
A quick decision guide:
- User stopped doing something? Debounce. (Search-as-you-type, autosave, validation.)
- Continuous feedback at a capped rate? Throttle. (Progress bars, drag tracking.)
- Element visibility or size?
IntersectionObserver/ResizeObserver— no event listener at all. - Per-frame animation tied to scroll?
requestAnimationFrame.
The mistake isn't usually picking debounce over throttle; it's reaching for either one when a purpose-built observer would do the work for free, off the main thread. Measure your handler's cost in DevTools' Performance panel, see how often it's actually running, and pick the tool that runs it the fewest times.
Takeaways
- High-frequency events (
scroll,resize,input,mousemove) can saturate the single-threaded event loop with expensive handlers. - Debounce runs once after events stop — use it for "the user finished" moments.
- Throttle runs at a capped rate during a stream — use it for continuous feedback.
- For visibility and size changes, prefer
IntersectionObserver/ResizeObserverover any scroll/resize handler. - For per-frame work, sync to
requestAnimationFrame. The cheapest handler is the one you never had to write.

