DevgainsDevgainsDevgains
All articles

Debounce, Throttle, and the Event-Loop Tax

·4 min read
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 / ResizeObserver over any scroll/resize handler.
  • For per-frame work, sync to requestAnimationFrame. The cheapest handler is the one you never had to write.
4 min read

Read next