Skip to content
JavaScript js advanced 4 min read

Debounce & Throttle

Some events fire far more often than you actually want to react to them. A user typing in a search box can trigger dozens of input events per second, and scrolling fires scroll continuously. If each event runs an expensive handler — a network request, a layout recalculation, a re-render — your UI stutters and your server gets hammered. Debounce and throttle are two closure-based techniques that tame this flood by deciding when a function is allowed to run.

The core difference

Both wrap a function and limit how often it executes, but they answer different questions:

  • Debounce waits for activity to stop. It postpones the call until a quiet period of delay milliseconds has passed with no new events. Great for “do this once the user is done.”
  • Throttle enforces a steady rate. It runs the function at most once every interval milliseconds while events keep coming. Great for “do this regularly, but not too often.”

A timeline makes it concrete. Imagine events firing rapidly, then a pause:

events:    X X X X X       X X
debounce:              D            D     (fires after a quiet gap)
throttle:  T     T     T   T     T        (fires on a fixed cadence)

Debounce collapses a burst into a single trailing call. Throttle samples the burst at regular intervals.

Implementing debounce

Debounce relies on a closure that remembers a timer ID between calls. Each invocation clears the pending timer and schedules a fresh one, so the function only runs once the calls stop.

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

const log = debounce((value) => console.log("Searching:", value), 500);

log("h");
log("he");
log("hel");
log("hello"); // only this one runs, 500ms after the last call

Output:

Searching: hello

We use a regular function (not an arrow) for the returned wrapper so that this and arguments come from the call site, then forward them with fn.apply(this, args). This keeps debounce usable as an event handler or object method.

Tip: For search-as-you-type, a 300-500ms debounce is the sweet spot — short enough to feel instant, long enough to skip intermediate keystrokes.

Leading-edge variant

Sometimes you want the first call to fire immediately and then suppress the rest. Add an immediate flag:

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

Implementing throttle

Throttle tracks the timestamp of the last run and ignores calls that arrive too soon. Here is a timestamp-based version:

function throttle(fn, interval = 200) {
  let lastRun = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastRun >= interval) {
      lastRun = now;
      fn.apply(this, args);
    }
  };
}

const onScroll = throttle(() => console.log("scroll at", Date.now()), 1000);
window.addEventListener("scroll", onScroll);

This fires on the leading edge and then at most once per interval. A common refinement adds a trailing call so the final event in a burst is never lost:

function throttle(fn, interval = 200) {
  let lastRun = 0;
  let timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastRun);
    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      lastRun = now;
      fn.apply(this, args);
    } else if (!timer) {
      timer = setTimeout(() => {
        lastRun = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

When to use which

ScenarioTechniqueWhy
Search / autocomplete inputDebounceWait until typing pauses before querying
Saving a draft as the user editsDebounceOne save after edits settle
Scroll position / infinite scrollThrottleSteady sampling, not after scrolling stops
Window resize re-layoutThrottle (or debounce for final layout)Limit costly reflows
Button to prevent double-submitDebounce (leading)Run once, ignore rapid repeats
Mouse-move drag trackingThrottleSmooth, bounded update rate

Warning: Throttle does not cancel — it spaces calls out. If you need the action to happen only after the user truly stops (e.g. a final API write), debounce is the correct tool, not throttle.

A self-contained demo you can edit live:

<input id="q" placeholder="Type to search..." />
<p id="out">waiting…</p>
<script>
  const debounce = (fn, d = 400) => {
    let t;
    return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), d); };
  };
  const out = document.getElementById("out");
  document.getElementById("q").addEventListener(
    "input",
    debounce((e) => { out.textContent = "Searching: " + e.target.value; }, 400)
  );
</script>

Best practices

  • Choose debounce for “after the user stops” and throttle for “at a steady cadence” — picking the wrong one is the most common mistake.
  • Use a regular function in the wrapper and forward this/args with apply so the helper works as a method or DOM handler.
  • Expose a cancel() method (clearing the timer) when you need to abort pending calls on component unmount to avoid stale updates and leaks.
  • Keep delays tunable; 300-500ms suits typing, 100-200ms suits scroll and mouse-move.
  • Always remove throttled/debounced listeners in cleanup code — the closed-over timer can otherwise keep references alive.
  • Reach for a vetted utility (lodash debounce/throttle) in production when you need edge options like maxWait, but understand the mechanics first.
Last updated June 1, 2026
Was this helpful?