JS / TS

Debounce vs Throttle: A Practical Guide With Code

2 min read

Some events fire faster than you want to respond to them. A user typing produces an event per keystroke; scrolling produces dozens per second. Respond to every one — an API call per keystroke, a layout calculation per scroll tick — and you've built jank. Debounce and throttle are the two classic answers, and people mix them up constantly. One analogy untangles them for good.

The elevator analogy

Debounce is an elevator door. Every time someone walks in, the door timer resets. The elevator only moves once people stop arriving for a moment. Activity delays the action; the action fires after a quiet period.

Throttle is the elevator itself. It goes at most once every N seconds, no matter how many people are pressing the button. Activity doesn't delay anything — the action just fires at a fixed maximum rate.

Same goal (fewer executions), opposite behavior under continuous activity: a debounced function might never run while activity continues; a throttled one runs steadily through it.

Debounce in practice: search inputs

You want to search when the user finishes typing, not on every keystroke:

const searchInput = document.querySelector('#search');
 
searchInput.addEventListener(
  'input',
  debounce((event) => {
    fetchResults(event.target.value);
  }, 300)
);

With a 300ms debounce, typing "typescript" fires one request instead of ten. The pause after the final keystroke is what triggers the search. Other natural debounce cases: form validation while typing, auto-save, resize handlers where only the final size matters.

Throttle in practice: scroll handlers

For scroll-linked UI — a reading-progress bar, an infinite-scroll check — waiting for scrolling to stop would be wrong. You want steady updates, just not sixty per second:

window.addEventListener(
  'scroll',
  throttle(() => {
    updateProgressBar(window.scrollY);
  }, 100)
);

At most ten updates per second: smooth enough for the eye, cheap enough for the main thread. Other throttle cases: drag/mousemove tracking, "is this element visible" checks, sending analytics pings during activity.

The implementations (no lodash required)

Understanding these two functions is worth more than a hundred usages of lodash.debounce. Debounce is a timer that keeps getting reset:

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Throttle is a gate that closes behind each call:

function throttle(fn, interval) {
  let ready = true;
  return function (...args) {
    if (!ready) return;
    ready = false;
    fn.apply(this, args);
    setTimeout(() => {
      ready = true;
    }, interval);
  };
}

Both rely on a closure remembering state (timer, ready) between calls — if that mechanism feels fuzzy, my closures explainer covers exactly what's happening. Production versions add options (leading/trailing edge, cancel), but the mechanism is the whole lesson: debounce resets a timer, throttle sets a cooldown.

Which one, in one question

Ask: do I care about the final state, or the ongoing activity?

  • Final state (finished typing, final window size) → debounce.
  • Ongoing activity (scroll position, drag position) → throttle.

If you take one thing from this post: setTimeout misuse isn't the bug — answering that question wrongly is. Debounce a progress bar and it stutters; throttle a search box and you query mid-word. Match the tool to the question and both problems disappear.

Found this useful?

Share

I post shorter takes on javascript & typescript and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.