DEV Community

Cover image for JavaScript Debounce vs Throttle: Differences and Examples
devTalk
devTalk

Posted on Originally published at dev-talk.com

JavaScript Debounce vs Throttle: Differences and Examples

A search box that fires a request on every keystroke, a scroll handler that recalculates layout 60 times a second, a "Save" button that gets double-clicked: all three are the same problem, a handler called far more often than it should be. Debouncing and throttling both fix that, but they fix it differently, and picking the wrong one gives you a search box that never searches or a progress bar that freezes. This takes about four minutes, and every number below comes from a run in Node 22.

The one-sentence difference

  • Debounce: wait until the events stop, then run once.
  • Throttle: run at most once per interval, however many events arrive.

To compare them, I fired 40 events 25 ms apart (about one second of "typing") at three versions of the same function, with a 300 ms wait:

Strategy Calls When they ran Arguments received
Nothing 40 every event 1 to 40
Debounce, 300 ms 1 about 300 ms after the last event (~1.3 s) 40
Debounce, leading edge 1 immediately (0 ms) 1
Throttle, 300 ms 5 0, 300, 600, 900, 1200 ms 1, 12, 24, 36, 40

Debounce gives you the final state once things settle. Throttle gives you a steady stream of updates while things are still happening, and still delivers the last value at the end.

Minimal implementations

You can use a library for this, but the logic is short enough to understand. These are the versions I tested:

export function debounce(fn, wait, { leading = false } = {}) {
  let timer = null;
  return function (...args) {
    const callNow = leading && timer === null;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, wait);
    if (callNow) fn.apply(this, args);
  };
}

export function throttle(fn, wait) {
  let last = 0;
  let timer = null;
  let lastArgs;
  return function (...args) {
    const now = Date.now();
    const remaining = wait - (now - last);
    lastArgs = args;
    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      last = now;
      fn.apply(this, args);
    } else if (timer === null) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, lastArgs);
      }, remaining);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Note that the leading-edge debounce above runs only on the first call and ignores the rest of the burst. That suits a submit button; it does not give you the final value. Lodash's _.debounce offers both edges through its leading and trailing options (trailing defaults to true, leading to false).

Which one for which job

Situation Use Why
Search-as-you-type, autosave, form validation on input Debounce Only the finished value matters
Window resize that recalculates layout Debounce Run once when the user lets go
Scroll position, drag, mousemove, progress updates Throttle You need updates during the action
Submit or "Like" button against double clicks Debounce, leading edge Act instantly, ignore the repeats
Sending analytics or heartbeat events Throttle Cap the rate, keep a steady flow

Four mistakes that bite

1. A debounce that never fires

Debounce waits for quiet. If events keep coming, it keeps waiting. I ran 120 events over three seconds with a 300 ms debounce: it made exactly one call, at about 3.3 seconds. The same stream through a throttle produced 12 calls, one every 300 ms. If you need a guarantee that the function runs at least every so often, lodash has a maxWait option on _.debounce ("the maximum time func is allowed to be delayed before it's invoked"). In the lodash source, _.throttle is literally debounce with maxWait set to wait. With lodash 4.17.21, 40 events at 25 ms gave the same result as my hand-written versions: debounce made 1 call with argument 40, throttle made 5 calls with arguments 1, 12, 24, 36 and 40.

2. Debouncing does not fix out-of-order responses

Debouncing reduces the number of requests. It does not stop an older, slower request from finishing after a newer one. I simulated a server where the query lar takes 400 ms and larav takes 50 ms, and sent lar first:

naive:  responses arrived: larav -> lar    | UI ends up showing: lar   (wrong)
abort:  lar:AbortError -> larav          | UI ends up showing: larav (right)
Enter fullscreen mode Exit fullscreen mode

The fix is to cancel the previous request when a new one starts:

let controller;

async function search(q) {
  controller?.abort();
  controller = new AbortController();
  try {
    const res = await fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
    render(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

input.addEventListener('input', debounce((e) => search(e.target.value), 300));
Enter fullscreen mode Exit fullscreen mode

Use both: the debounce saves requests, and the abort guarantees the screen shows the latest answer.

3. Recreating the wrapper on every render

A debounced or throttled function keeps its timer in a closure. If you create a new one on each render (common in React components), each call gets a fresh timer and nothing is ever actually debounced. Create it once: at module level, or memoized so it survives re-renders. Also cancel any pending call when the component or page goes away; lodash's debounced functions expose cancel() and flush() for that.

4. Reaching for requestAnimationFrame to throttle scroll

MDN states that throttling the scroll handler with requestAnimationFrame() is useless, because animation frame callbacks fire at the same rate as scroll handlers, and recommends measuring a timeout yourself (its example uses setTimeout). If you only need to know when scrolling has finished, MDN points to the scrollend event instead of debouncing scroll. Check browser support for it before relying on it.

Choosing a wait time

There is no universal number, so treat these as starting points and tune by feel: a few hundred milliseconds for typing (the examples I found in the lodash docs use 150 ms for resize and 300 ms for a click handler), and roughly 100 to 250 ms for throttled scroll or mouse work. Too short and you barely reduce the load; too long and the interface feels broken.

Frequently asked questions

Is throttle just debounce with a maximum wait?

Effectively, yes. Lodash implements _.throttle by calling its debounce logic with maxWait equal to wait.

Do I need lodash for this?

No. The two functions above are enough for most cases. Use a library when you need cancel, flush, leading and trailing together, or maxWait without writing and testing them yourself.

Does debouncing replace server-side rate limiting?

No. It only reduces the load a well-behaved browser sends. Anyone can call your API directly, so limits still belong on the server.

References


Originally published on DEV Talk.

Top comments (0)