Skip to content
yutils

How Debouncing and Throttling Actually Work

Debounce vs throttle for taming high-frequency events like scroll, resize, and input — leading vs trailing edge, real JS code, React pitfalls, and INP gains.

~8 min read

Every keystroke fires an API call; a single scroll runs your handler hundreds of times a second — and the UI stutters. The problem isn't the event, it's the frequency. scroll, resize, mousemove, and input fire dozens to hundreds of times per second, and running an expensive handler each time clogs the main thread. The fix is to run it fewer times — debounce and throttle. This guide covers the difference, correct vanilla JS implementations, the React pitfalls, and the tie-in to INP.

The problem — events flood

# Search input, user types "hello"
h     → API call
he    → API call
hel   → API call
hell  → API call
hello → API call
= 5 calls. Only the last one matters.

# One scroll (a single mouse-wheel tick)
The scroll event fires every ~16ms → 60+ times per second.
A handler that measures layout + setState hogs the main thread → jank.

The core intuition — debounce "waits until things go quiet"; throttle "lets one call through per N ms".

Debounce — collapse a burst into one

Coalesce a run of calls into a single execution that fires once the calls have been silent for N ms. "Search when typing stops" is the classic case:

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

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

input.addEventListener("input", (e) => search(e.target.value));

Each call cancels the previous timer with clearTimeout and sets a fresh one. fn only runs after calls go quiet for 300ms. Type "hello" quickly and the API fires once.

Leading edge vs trailing edge

burst:  | | | | |              (5 calls, gaps < delay)
            ──────────────────▶ time

trailing (default): one call, delay after the LAST call
                    |...wait...| → fire (at the end)

leading: fire immediately on the FIRST call, ignore the rest
         fire → | | | | (ignored)

both: fire on the first call AND once at the end

Trailing is the default — "fire once it's all over". Good for search and autosave. Leading is for "the first click should react immediately" (e.g. a submit-once button). An implementation with options:

function debounce(fn, delay, opts = {}) {
  const {leading = false, trailing = true} = opts;
  let timer = null;
  let calledLeading = false;

  function debounced(...args) {
    if (leading && !timer) {
      fn.apply(this, args);
      calledLeading = true;
    }
    clearTimeout(timer);
    timer = setTimeout(() => {
      if (trailing && !(leading && calledLeading)) {
        fn.apply(this, args);
      }
      timer = null;
      calledLeading = false;
    }, delay);
  }

  debounced.cancel = () => {
    clearTimeout(timer);
    timer = null;
    calledLeading = false;
  };
  return debounced;
}

cancel() discards a pending call — essential for cleanup on unmount (see pitfalls below). Adding flush() ("run the pending call right now") is also common.

Where debounce fits

  • Search-as-you-type — query when typing stops
  • Autosave — save when edits pause
  • Validate-on-stop — validate after input ends
  • Resize-end — recompute layout after the window settles

Throttle — at most once per N ms

Run at most once every N ms while a burst continues. Unlike debounce, it fires periodically during the burst. Use it when you can't wait until the end, like tracking scroll position. A timestamp-based implementation:

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

window.addEventListener(
  "scroll",
  throttle(() => {
    updateScrollProgressBar(window.scrollY);
  }, 100)
);

This simple version is leading-only — it fires on the first call and drops the rest within the interval. The downside: if the last call of a burst lands just before the interval boundary, it's lost. To guarantee the final value, add a trailing timer:

function throttle(fn, interval) {
  let last = 0;
  let timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - last);
    if (remaining <= 0) {
      // leading edge — run now
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      last = now;
      fn.apply(this, args);
    } else if (!timer) {
      // trailing edge — run the last value at the boundary
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

Now it's both leading + trailing. Even when the burst stops, one final call is guaranteed. This is the default behavior of lodash's throttle.

Where throttle fits

  • Scroll position tracking — progress bar, sticky header
  • Infinite-scroll trigger — detect "near the bottom"
  • Drag / mousemove — update coordinates while dragging
  • Analytics — sample high-frequency events

requestAnimationFrame throttle — align to frames

Work that repaints the screen (scroll, drag) is better aligned to frames than to a time interval like 100ms. The browser paints every ~16.7ms (60fps), so updating more often than that is invisible. requestAnimationFrame runs once per frame:

function rafThrottle(fn) {
  let scheduled = false;
  let lastArgs = null;
  return function (...args) {
    lastArgs = args;
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      fn.apply(this, lastArgs);
    });
  };
}

window.addEventListener(
  "scroll",
  rafThrottle(() => {
    header.style.transform = "translateY(" + (-window.scrollY * 0.3) + "px)";
  })
);

Benefits — it auto-aligns to the paint cycle, and rAF pauses in background tabs so no wasted work. The rule: use rAF for visual effects, time-based throttle for non-visual logic (like API calls).

Debounce vs throttle — the decision rule

One-line rule — "debounce when only the final value matters; throttle when you need to react periodically while it's still happening".

                debounce              throttle
──────────────────────────────────────────────────────
when it runs     once after quiet      once per N ms
during a burst   doesn't run           runs periodically
the metaphor     "search when typing   "at most N times
                  stops"                per second"
──────────────────────────────────────────────────────
search input     ✓                     △ (less suitable)
autosave         ✓                     △
scroll tracking  ✗ (never arrives)     ✓
drag coords      ✗                     ✓
resize-end       ✓                     △
infinite scroll  △                     ✓

Mix them up and the behavior is wrong. Debounce a scroll progress bar and it won't move while scrolling, then jumps when you stop. Throttle a search and you fire calls for intermediate keystrokes.

React pitfalls — inline debounce is broken

// Broken — a new debounced function on every render
function SearchBox() {
  const [q, setQ] = useState("");

  // debounce(...) is recreated on every render
  // → its internal timer resets each time → zero debounce effect
  const onChange = debounce((v) => fetchResults(v), 300);

  return <input onChange={(e) => onChange(e.target.value)} />;
}

The problem — when setQ changes state the component re-renders, and each render calls debounce(...) again, returning a brand-new function. The new function has its own timer variable, unrelated to the previous pending timer — so nothing is ever debounced.

Fix 1 — useMemo to build the debounced function once:

function SearchBox() {
  const [q, setQ] = useState("");

  const debouncedFetch = useMemo(
    () => debounce((v) => fetchResults(v), 300),
    [] // no deps — created once on mount
  );

  // clear the pending timer on unmount
  useEffect(() => () => debouncedFetch.cancel(), [debouncedFetch]);

  return (
    <input
      value={q}
      onChange={(e) => {
        setQ(e.target.value);
        debouncedFetch(e.target.value);
      }}
    />
  );
}

Fix 2 — useRef for a stable instance. React explicitly says it may throw away a useMemo cache, so when you need "never recreate", a ref is safer:

function SearchBox() {
  const fetchRef = useRef(null);
  if (!fetchRef.current) {
    fetchRef.current = debounce((v) => fetchResults(v), 300);
  }
  useEffect(() => () => fetchRef.current.cancel(), []);
  // call fetchRef.current from onChange
}

The useCallback pitfall — stale closures

Even if you memoize a handler with useCallback, a debounced function can capture stale props/state and cause a stale-closure bug:

// debouncedSave remembers the count from when it was first created
const save = useMemo(
  () => debounce(() => api.save(count), 500),
  [] // count is missing → always saves the first count (0)
);

The fix — pipe the latest value into a ref and read the ref inside the debounced function. The debounced function never needs to be recreated yet always sees fresh data:

const countRef = useRef(count);
countRef.current = count; // refresh every render

const save = useMemo(
  () => debounce(() => api.save(countRef.current), 500),
  []
);

React-native alternatives — useDeferredValue / useTransition

When the slowdown is heavy rendering driven by input (filtering a big list, say), React 18+ concurrency features fit more naturally than a timer-based debounce:

function Filter() {
  const [query, setQuery] = useState("");
  // query updates instantly; the heavy derived value is deferred
  const deferred = useDeferredValue(query);
  const list = useMemo(() => filterHugeList(deferred), [deferred]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results data={list} />
    </>
  );
}

useDeferredValue keeps the input snappy while deprioritizing the derived render. useTransition marks a state update as explicitly "non-urgent". Unlike debounce, both adapt to the browser's spare time instead of a fixed N ms. But for cutting down network calls, debounce is still the right tool — concurrency features are for render load.

Common pitfalls

1. Not clearing timers on unmount

Leave a pending timer and its callback runs after unmount, calling setState on a gone component → warning / memory leak. Always cancel() (or clearTimeout) in cleanup:

useEffect(() => {
  const handler = throttle(onScroll, 100);
  window.addEventListener("scroll", handler);
  return () => {
    window.removeEventListener("scroll", handler);
    handler.cancel && handler.cancel(); // clear the pending timer
  };
}, []);

2. Stale closures

As above, the debounced/throttled function traps old variables. Empty deps = fast but stale; full deps = recreated every time, so debounce breaks. The ref pattern avoids both.

3. Different instance for add vs remove

Wrapping inline, like addEventListener(throttle(fn)), means removeEventListener gets a different instance and fails to detach. Store it in a variable and add/remove the same reference.

4. Picking the delay

  • Search debounce — 250–400ms (matches a human typing pause)
  • Scroll throttle — 100–200ms; use rAF for visual effects
  • Too long feels sluggish; too short barely helps

Why this matters for performance — INP

Left unchecked, high-frequency handlers pile up long tasks (50ms+ of main-thread time), and the user's clicks and keystrokes lag. That's exactly what hurts INP (Interaction to Next Paint), a Core Web Vital. Debouncing or throttling those handlers cuts the number of executions, shrinks long tasks, and improves INP.

One level deeper — mixing layout reads like getBoundingClientRect with DOM writes inside a handler triggers repeated forced reflow. Throttle to cut the count, batch visual updates into rAF, and separate reads from writes. How events queue up and get processed is covered in the event loop guide; measuring INP is covered in the Core Web Vitals guide.

Library or roll your own?

lodash.debounce / lodash.throttle ship leading/trailing/maxWait/cancel/flush in a battle-tested form. If you already use lodash, there's little reason to hand-roll. But if you'd rather not add a dependency — or you want to understand the behavior fully — the 10–20 lines above are enough. In React, a dedicated package like use-debounce handles the ref pitfalls for you.

Summary

  • Running high-frequency events (scroll/resize/mousemove/input) as-is clogs the main thread and causes jank.
  • Debounce — once after quiet. Search, autosave, validate-on-stop, resize-end.
  • Throttle — once per N ms. Scroll tracking, infinite scroll, drag, analytics.
  • Decision rule — debounce when only the final value matters; throttle when you need periodic reaction during the burst.
  • Align visual updates with a requestAnimationFrame throttle (~60fps).
  • React — inline debounce(fn) is recreated every render and breaks. Pin the instance with useMemo / useRef, and pass fresh values via a ref to avoid stale closures.
  • Use useDeferredValue / useTransition for render load; debounce for network calls.
  • Clear timers with cancel() in cleanup — otherwise leaks / setState-after-unmount. Fewer calls = fewer long tasks = better INP.
Back to guides