Back to blog
Aug 2026·4 min readAI-generated

Debouncing and Throttling: Taming High-Frequency Events in JavaScript

#javascript#performance#frontend

Every frontend developer eventually hits the same wall: a scroll handler firing hundreds of times a second, a search input triggering a network request on every keystroke, or a resize listener recalculating layout so often the page starts to stutter. Debouncing and throttling are the two techniques that fix this, and once you know the difference between them, picking the right one becomes automatic.

The problem: events fire faster than you can handle them

Browsers don't rate-limit DOM events for you. scroll, resize, mousemove, and input can all fire dozens of times per second. If the handler attached to one of them does anything non-trivial — a fetch, a DOM measurement, a re-render — you're doing that expensive work far more often than the user actually needs.

window.addEventListener('resize', () => {
  recalculateLayout(); // runs on every single pixel of resize
});

Debouncing and throttling both cut down how often the handler actually runs — they just do it with different guarantees.

Debounce: wait for the silence

Debouncing delays execution until the events stop for a given period. Every new call resets the timer. It's the right tool when you only care about the final state after a burst of activity — the value in a search box once the user stops typing, not every intermediate keystroke.

function debounce(fn, delayMs) {
  let timeoutId;
  return function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  };
}

Usage: a search-as-you-type box that shouldn't hit the network on every keystroke.

const searchInput = document.querySelector('#search');

const runSearch = debounce((event) => {
  fetchResults(event.target.value);
}, 300);

searchInput.addEventListener('input', runSearch);

Type "angular" and fetchResults fires once, 300ms after the last keystroke — not seven times.

Throttle: guarantee a steady rhythm

Throttling caps execution to at most once per interval, regardless of how many events come in during that window. It's the right tool for continuous streams where you need periodic updates throughout, not just at the end — a scroll position tracker, a drag handler, a mousemove-driven cursor effect.

function throttle(fn, intervalMs) {
  let lastCall = 0;
  let timeoutId = null;

  return function throttled(...args) {
    const now = Date.now();
    const remaining = intervalMs - (now - lastCall);

    if (remaining <= 0) {
      clearTimeout(timeoutId);
      timeoutId = null;
      lastCall = now;
      fn.apply(this, args);
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {
        lastCall = Date.now();
        timeoutId = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

Usage: recalculating layout on resize, at most every 200ms.

window.addEventListener(
  'resize',
  throttle(() => {
    recalculateLayout();
  }, 200),
);

Note the else if (!timeoutId) branch: it schedules one trailing call so the last resize event in a burst still gets handled, instead of being silently dropped because it landed inside the cooldown window.

Choosing between them

  • Debounce when you only care about the end state: search input, form validation, auto-save, resize handlers that only need to run once settling has finished.
  • Throttle when you need continuous feedback during the activity: infinite scroll triggers, scroll-position progress bars, drag-and-drop, live cursor tracking.

A rule of thumb: debounce answers "what did they end up doing?"; throttle answers "what are they doing right now, sampled regularly?"

Common pitfalls

  • Losing this. Both implementations above use fn.apply(this, args), so they work correctly as object methods or event handlers bound the normal way. If you wrap an arrow function that already closed over the wrong this, no amount of .apply() will fix it — the mistake happens earlier.
  • Not cleaning up on unmount. In a component-based framework (React, Angular, Vue), a debounced/throttled handler can still be pending in a setTimeout after the component that created it is gone, and firing it then can crash or write to stale state. Cancel it: keep a reference to timeoutId and clear it in the cleanup path (ngOnDestroy, useEffect's cleanup function, etc.).
  • Debounce delay set too long. 300ms is a common default for search inputs, but for something like inline form validation, even 300ms can feel laggy. Tune the delay to the interaction, not to a number you copied from a blog post.
  • Throttle without a trailing call. A naive throttle that only checks "has enough time passed?" and does nothing otherwise will silently drop the final event of a burst. If the last state matters (and it usually does), make sure your implementation schedules a trailing call like the one above.

Should you write your own?

The implementations here are deliberately minimal to show the actual mechanism — closures holding onto timer state between calls. In production code, reach for a maintained utility instead: debounce and throttle from Lodash handle edge cases like leading/trailing-edge configuration and .cancel()/.flush() methods that are easy to get subtly wrong by hand. Writing your own is a good exercise for understanding what's happening under the hood — reaching for a battle-tested one is the right call once it matters.

Takeaway

Debounce collapses a burst of events into one call after it settles. Throttle spreads calls out evenly while the burst is happening. Both exist for the same reason: the browser doesn't know how fast your code can afford to run, so it's on you to tell it.

NewerUnderstanding Web Workers for Heavy ComputationOlderWriting a Simple Command-Line Tool in Node.js