Debouncing vs Throttling: Only the Last Event, or the Ones in Between?

Debounce waits for silence and fires once; throttle watches the clock and fires steadily. The search box makes debounce look universal — a drag handler at 60 events/sec proves it isn't. Both mechanisms in plain JavaScript, both honest failure modes (debounce can starve forever, a naive throttle drops the final event), and a rule for picking one.

Banner

Prefer to watch? ▶ Debouncing vs Throttling in 100 seconds ✈ Telegram

Ten keystrokes in a search box fire ten input events — and ten API calls, nine of them wasted before the tenth even lands.

That's the bug everyone has written once. The fix everyone reaches for is "just debounce it," and in a search box that fix is correct. Which is exactly why so many people think debounce is the answer to every noisy event stream. It isn't.

The real question is never "how do I fire fewer events?" The event itself is free — it's a function call in the browser's event loop. What isn't free is the work behind it: the network round trip, the layout recalculation, the write to disk. So you need a rule for which events become work. There are two rules, and they disagree.

  • Debounce waits for the stream to go quiet, then fires once.
  • Throttle lets one through, then ignores the rest until a fixed interval has elapsed.

Neither one is a timer that runs on its own. No events, no calls.


Debounce: wait for silence

Every incoming event cancels the pending callback and restarts the clock. The callback only survives to run if nothing interrupts it for the full delay.

function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);              // cancel whatever was pending
    timer = setTimeout(() => fn(...args), delay);  // restart the clock
  };
}

const search = debounce((q) => fetch(`/api/search?q=${encodeURIComponent(q)}`), 300);

That's the whole mechanism — six lines, clearTimeout and setTimeout. Type ten characters quickly and you get one request, carrying the final query. Perfect, because in a search box only the last keystroke is real. The nine intermediate queries were never answers anyone wanted.

What debounce costs you

Move the same code to a drag handler and the trade-off flips into view. Drag a slider and you get exactly one call — after you let go. Which means that for the entire duration of the drag, nothing happens. No preview, no live value, no progress. The UI looks frozen, and users read frozen as broken.

Then there's the failure mode that most explanations skip entirely:

If the events never stop, the timer never survives to fire.

Debounce guarantees a call after silence. It guarantees nothing if silence never arrives. A user typing steadily for 60 seconds without a single 300 ms pause triggers zero saves. Wrap autosave in a plain debounce(save, 300) and you have shipped a feature that, under exactly the conditions it exists for — sustained writing — saves nothing at all.

Debounce can starve forever. Throttle cannot.


Throttle: watch the clock, not the silence

Throttle ignores whether the stream is quiet. It keeps a timestamp and asks one question per event: has the interval elapsed?

function throttle(fn, interval = 300) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= interval) {     // window open?
      last = now;
      fn(...args);
    }
    // else: drop this event on the floor
  };
}

const onDrag = throttle((e) => updatePosition(e.clientX), 100);

Same 60 events a second, but now the handler fires steadily throughout the drag — it tracks your finger. Progress is guaranteed regardless of how long the input lasts, because the clock advances whether or not the user pauses.

What throttle costs you

The naive timestamp gate above is leading-edge only: it runs on the first event of each window and drops everything after. So the final event of the stream — the one that carries where the user actually stopped — arrives inside a window that's already closed, and gets dropped with the rest.

You end up storing a position that is one step stale, permanently. The slider snapped to 47 on screen, your backend thinks 43. That's why every real throttle implementation adds a trailing call: when the window closes, fire once more with the most recent arguments if any were dropped.


Side by side

 DebounceThrottle
TriggerSilence — N ms with no new eventThe clock — one call per N ms
MechanismclearTimeout + setTimeout on every eventTimestamp gate (Date.now() - last >= interval)
Calls during a 5s burst0 (then 1 at the end)~5s / interval, spread evenly
Guarantees progress?No — can starve under continuous inputYes — fires regardless of pauses
Sees the final event?Yes, always — that's the pointOnly with a trailing call bolted on
Failure modeFrozen UI mid-stream; zero calls if input never pausesPermanently one step stale (leading-edge only)
Reach for it whenAutocomplete, validate-on-stop, resize settleDrag, scroll, autosave, progress, telemetry

The rule worth memorising

Every framework, every language, same rule:

Debounce = only the LAST event matters. Throttle = the ones in between matter too.

Ask that question about your handler and the choice answers itself. Autocomplete? Only the final query is a real query — debounce. Drag preview? Every intermediate position is a frame the user is watching — throttle. Autosave? The intermediate states genuinely matter and the stream may never pause — throttle, or debounce with a maxWait so it can't starve.

That maxWait option in lodash's debounce isn't decoration. It's the escape hatch for exactly the autosave bug above: fire after 300 ms of silence, but never go longer than 5 s without firing, whatever the user does.


The same two rules, one layer up: LLM and AI apps

This isn't a frontend-only pattern. The moment your app calls a model, the "event is free, the work behind it isn't" asymmetry gets much sharper — an input event costs nothing, an embedding call plus a vector search plus a generation costs money and hundreds of milliseconds.

  • Debounce the query, not the keystroke. RAG-backed search-as-you-type should never embed on every character. Debounce at 250–400 ms so one embedding + retrieval fires per thought, not per letter. Only the last query is a real query — textbook debounce.
  • Throttle the token stream into the UI. A streaming response arrives as many small chunks per second. Re-rendering markdown on every token burns main-thread time for frames nobody perceives. Throttle the render to ~30–60 ms — the intermediate tokens matter (that's the whole point of streaming), just not all of them. And you must add the trailing call, or the last chunk lands in a closed window and the answer renders permanently truncated by a few words. This is the leading-edge bug, shipped.
  • Throttle outbound calls to respect provider rate limits. A token-bucket throttle in front of the provider client is what keeps a burst of agent tool-calls under the requests-per-minute ceiling. Debounce would be actively wrong here — it would collapse a queue of distinct, all-necessary calls into one.
  • Debounce-with-maxWait for agent state checkpoints. Persisting conversation or agent scratchpad state on every step is wasteful; debouncing it alone risks a long-running agent that never pauses and therefore never checkpoints — the autosave starvation bug, now with an hour of lost work behind it.

Same two questions, bigger blast radius: does only the final one matter, and can this stream ever fail to pause?


Verdict

Debounce and throttle are not competing implementations of one idea — they encode two different beliefs about your event stream.

Use debounce when intermediate events are noise on the way to a real one: autocomplete, validation on stop, resize settle, search-as-you-type embeddings.

Use throttle when intermediate events are themselves the product, or when the stream might never go quiet: drag, scroll, progress, telemetry, streaming-token rendering, rate-limited API calls.

And whichever you pick, handle its cost explicitly — a maxWait on debounce so it can't starve, a trailing call on throttle so it can't go stale. Both are six lines of plain setTimeout and Date.now(). Reaching for the lodash import before you can write either one by hand is how the autosave bug ships.


Watch the reel: Debouncing vs Throttling in 100 seconds — the drag-handler test and both failure modes, animated.

Debouncing vs Throttling: Only the Last Event, or the Ones in Between? | Vahid Aghajani