DEV Community

Cover image for Debounce and Throttle in JavaScript: The Complete Guide
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Debounce and Throttle in JavaScript: The Complete Guide

Type "javascript" into an unthrottled search box and watch the network tab: an HTTP request for j, another for ja, another for jav, ten in total, nine of them thrown away before the response even lands. The server did ten times the work the feature needed, and the UI flickers with results for a query the user abandoned two keystrokes ago. The fix is two small, deceptively simple functions — debounce and throttle — and the part that trips people up isn't writing them, it's knowing exactly when each one fires.

What you'll learn

By the end of this guide you'll be able to:

  • Explain the precise difference between debounce and throttle, not just "they both slow things down"
  • Write a correct debounce and a correct throttle from scratch, including leading/trailing edge behavior
  • Choose the right one for search inputs, scroll handlers, resize handlers, and button clicks
  • Avoid the memory leaks and stale-closure bugs both patterns cause in React and vanilla JS alike
  • Use a copy-paste utility with cancel() and flush() support

Who this is for: you write JavaScript day to day, you've attached an event listener before, and you've either hand-rolled a setTimeout hack for this exact problem or reached for lodash without fully trusting what its defaults do.

Contents

Why debounce and throttle exist

Here's the naive version of a live search box — the one almost everyone writes first:

// the wrong way — fires a request on every single keystroke
searchInput.addEventListener("input", (event) => {
  fetchResults(event.target.value); // one HTTP request per keystroke
});
Enter fullscreen mode Exit fullscreen mode

Type a six-letter word at a normal pace and this fires six requests in well under a second, five of which are already obsolete by the time their responses arrive. Worse, network responses don't always resolve in the order they were sent — a slow response for jav can arrive after the fast response for javascript, and now the screen is showing stale results for a query the user already replaced. The bug isn't visible in a quick manual test because your laptop and the API are both fast; it shows up for a real user on a real network, and by then it looks like "search is flaky" rather than "search fires way too often."

The same shape of problem hits scroll and resize handlers, just with a different failure mode. A scroll event can fire dozens of times per second. If the handler does anything nontrivial — reading getBoundingClientRect(), updating layout, running a chunk of business logic — the page starts dropping frames and scrolling turns jerky, even though nothing is technically "broken."

Both problems come from the same root cause: the event source fires far more often than the response actually needs to run. Debounce and throttle are two different answers to "how often is often enough," and they are not interchangeable.

The mental model: a bouncer, not a filter

The mental model: neither function filters events — every event still reaches your wrapper and every event still runs a check. What changes is how often the check lets the real work through, and the two functions use opposite strategies for deciding that.

  • Debounce says "wait for quiet." Every call resets a timer. The wrapped function only runs once the calls actually stop for the configured delay. Think of an elevator door: every time someone walks up, the door resets its close timer. It only closes once nobody has approached it for a few seconds.
  • Throttle says "at most once per interval." It doesn't care whether calls are still coming in — it just refuses to let the wrapped function run again until a fixed amount of time has passed since the last time it ran. Think of a metronome, or a bouncer who lets one person through the door every two seconds regardless of how long the line is.

That single distinction — "wait for silence" versus "space it out at a fixed rate" — explains almost every behavior difference in the rest of this guide. Debounce is right when you only care about the final state (the finished search query). Throttle is right when you need regular updates during continuous activity (a scroll position that should keep updating while the user scrolls).

Stage 1: debounce from scratch

The smallest correct debounce is short — a closure holding one timer ID:

function debounce(fn, delayMs) {
  let timeoutId; // lives across calls thanks to the closure
  return function debounced(...args) {
    clearTimeout(timeoutId); // cancel whatever was pending
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  };
}

const debouncedSearch = debounce((query) => fetchResults(query), 300);
searchInput.addEventListener("input", (e) => debouncedSearch(e.target.value));
Enter fullscreen mode Exit fullscreen mode

Key concept: every call to debounced cancels the previous pending timer and starts a new one. fn only ever actually runs if 300ms pass with no new call in between — which is exactly "wait for quiet," implemented as literally as possible.

Type "js" quickly and debounced runs twice (once per keystroke) but fn runs zero times until you stop — then it runs exactly once, 300ms after your last keystroke, with the final value of query. That's the whole mechanism. Everything else in this guide is a variation on this six-line function.

Stage 2: throttle from scratch

Throttle needs to track when it last ran, not whether a timer is pending:

function throttle(fn, intervalMs) {
  let lastRun = 0; // timestamp of the last time fn actually executed
  return function throttled(...args) {
    const now = Date.now();
    if (now - lastRun >= intervalMs) {
      lastRun = now;
      fn.apply(this, args);
    }
  };
}

const throttledOnScroll = throttle(() => updateScrollProgress(), 100);
window.addEventListener("scroll", throttledOnScroll);
Enter fullscreen mode Exit fullscreen mode

Key concept: this implementation runs fn immediately on the very first call (because now - lastRun starts effectively infinite), then ignores every call until intervalMs has elapsed, at which point the next call through gets to run. Calls that arrive during the "cooldown" are dropped entirely — not queued, not delayed, just discarded.

That last detail matters: with this specific implementation, if the burst of calls stops during a cooldown window, the very last call in the burst is simply lost — fn doesn't get one final run with the latest arguments. Stage 3 fixes that.

Stage 3: leading and trailing edges

"Leading edge" means running on the first call in a burst; "trailing edge" means running once more after the burst ends, with the latest arguments. The debounce in Stage 1 is trailing-only. The throttle in Stage 2 is leading-only. A production-grade version usually supports both, because dropping the trailing call (throttle) or delaying every call including the first one (debounce) is sometimes the wrong tradeoff:

function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
  let lastRun = 0;
  let timeoutId = null;
  let lastArgs = null;

  return function throttled(...args) {
    const now = Date.now();
    const remaining = intervalMs - (now - lastRun);
    lastArgs = args;

    if (remaining <= 0) {
      if (leading || lastRun !== 0) {
        lastRun = now;
        fn.apply(this, args);
      }
    } else if (trailing && !timeoutId) {
      timeoutId = setTimeout(() => {
        timeoutId = null;
        lastRun = Date.now();
        fn.apply(this, lastArgs);
      }, remaining);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Key concept: leading controls whether the very first call in a burst runs immediately; trailing controls whether one extra call fires after the burst goes quiet, using whatever arguments arrived last. lodash's _.throttle defaults to { leading: true, trailing: true }, and its _.debounce defaults to { leading: false, trailing: true } — which is exactly why debounce "feels like" it only fires at the end, while throttle "feels like" it fires immediately and then periodically.

Stage 4: cancel, flush, and cleanup

A debounce or throttle you can't cancel is a liability the moment its owner disappears — a component unmounts, a modal closes, a request is superseded. Attach the controls directly to the returned function:

function debounce(fn, delayMs) {
  let timeoutId;
  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  }
  debounced.cancel = () => clearTimeout(timeoutId); // drop the pending call
  return debounced;
}

const debouncedSave = debounce(saveDraft, 500);
debouncedSave("draft text");
debouncedSave.cancel(); // "draft text" will never be saved
Enter fullscreen mode Exit fullscreen mode

flush() is the mirror image: run the pending call right now instead of waiting or dropping it — useful when the user explicitly submits a form while a debounced autosave is still pending, so the two writes don't race.

Stage 5: using them in React

The trap in React isn't the debounce function itself — it's where you create it. Creating a new debounced function on every render breaks the whole mechanism, because each render's closure has no memory of the previous render's timer:

// the wrong way — a brand-new debounce (and a brand-new timer) every render
function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedSearch = debounce((q) => fetchResults(q), 300); // recreated every render

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        debouncedSearch(e.target.value); // this instance's timer never gets to fire before a new one replaces it
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Create the debounced function once, with useMemo or useRef, and cancel it on unmount:

function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedSearch = useMemo(() => debounce((q) => fetchResults(q), 300), []);

  useEffect(() => {
    return () => debouncedSearch.cancel(); // no fetch after this component is gone
  }, [debouncedSearch]);

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        debouncedSearch(e.target.value);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Key concept: the debounced function must outlive individual renders (created once, stored in a ref or memoized with an empty dependency array) and must be explicitly cancelled in a cleanup function, or its pending timer will call fetchResults on state that no longer exists.

Edge cases and gotchas

  • Stale closures. If fn inside a debounce/throttle closes over a variable that changes between calls (a piece of state, a prop), the call that eventually fires can use an outdated value. useCallback/useRef patterns exist specifically to solve this in React; in vanilla JS, pass the current value as an argument rather than relying on the closure.
  • this binding. The hand-written implementations above use fn.apply(this, args) so a debounced/throttled method still sees the right this. If you strip that out, calling debounce(obj.method, 300)() silently breaks this inside method.
  • Debouncing an async function doesn't cancel in-flight work. Debounce delays calling the function; it does nothing about a fetch that's already in progress from a previous call. Pair debounce with an AbortController if a slow, superseded request could still resolve and overwrite a newer one.
  • Race conditions between the trailing call and unmount. A debounce's setTimeout keeps a reference to fn alive even after the component that created it is gone. Without cancel() on cleanup, the trailing call still fires and can throw ("cannot update state on an unmounted component") or write to a resource that no longer applies.
  • Throttle intervals and animation. A scroll or mousemove throttle set to a fixed millisecond interval can visibly stutter, because it's not synchronized with the browser's paint cycle. For anything visual, prefer requestAnimationFrame-based throttling: run at most once per frame instead of once per N milliseconds.
  • Testing. Both patterns depend on real time passing, which makes tests flaky if you sleep. Use fake timers (jest.useFakeTimers() / vi.useFakeTimers()) and advance them explicitly (jest.advanceTimersByTime(300)) instead of waiting on the wall clock.
  • Debounce delay versus perceived responsiveness. A 300ms debounce feels instant to most users; anything above ~500ms on a search-as-you-type field starts to feel sluggish, because the user has already mentally "sent" the query.

Best practices (when (not) to use them)

Reach for debounce when you only care about the value once activity settles: search-as-you-type, autosave, form validation that shouldn't run on every keystroke, resize-triggered layout recalculation where only the final size matters.

Reach for throttle when you need periodic updates during continuous activity, not just at the end: scroll-position tracking, a progress indicator following mousemove, rate-limiting how often a "user is typing" indicator pings a server, infinite-scroll trigger checks.

Avoid both when the action needs to feel instantaneous every single time — a button click, an "add to cart," a keyboard shortcut. Delaying or dropping those erodes trust in the UI even if it's technically more "efficient." If a click handler is slow, fix the handler; don't debounce the click.

Don't stack them by accident. Wrapping an already-throttled handler in another library's debounce (or vice versa) is a common cause of "my scroll handler feels randomly laggy" bugs — pick one strategy per event source and be deliberate about the delay.

FAQ

What's the actual difference between debounce and throttle?

Debounce waits for a pause in activity and then runs once; throttle runs at a fixed maximum rate regardless of whether activity is still ongoing. Debounce answers "what's the final state?"; throttle answers "give me periodic updates while this keeps happening."

Does lodash's _.debounce behave differently from a hand-rolled one?

Functionally, a correct hand-rolled trailing-edge debounce matches _.debounce's default behavior (leading: false, trailing: true). lodash additionally ships cancel(), flush(), and a maxWait option (a ceiling on how long calls can be delayed even under continuous activity) — genuinely useful extras, not a different core algorithm.

Can I debounce an async function?

Yes, but debounce only controls when the call happens — it has no knowledge of what the async function does afterward. If an earlier (superseded) call's promise resolves after a later one, you can still get out-of-order results unless you also track "is this the latest call" or cancel the earlier request with an AbortController.

Should I use requestAnimationFrame instead of throttle for scroll or resize?

For anything that updates visuals (position, size, opacity), yes — requestAnimationFrame throttling caps the work at once per paint, which is both smoother and never more work than the browser can actually display. Millisecond-based throttling is still the right tool for non-visual rate-limiting, like capping how often you ping an analytics endpoint.

How do I cancel a debounced or throttled call?

Attach a .cancel() method to the returned function (Stage 4) and call it — in setTimeout's case that's a clearTimeout; for lodash, _.debounce and _.throttle both return functions with a built-in .cancel().

Cheat sheet

Task Code Notes
Debounce (trailing only) debounce(fn, 300) Runs once, 300ms after calls stop. Best for search/autosave.
Throttle (leading + trailing) throttle(fn, 100, { leading: true, trailing: true }) Runs immediately, then at most every 100ms, plus once more after the burst ends.
Cancel a pending call debounced.cancel() Clears the timer; the delayed call never runs.
Flush a pending call now debounced.flush() Runs the pending call immediately instead of waiting.
Visual/animation rate-limit requestAnimationFrame loop Caps work to once per paint; smoother than a fixed-ms throttle for scroll/resize.
React: create once useMemo(() => debounce(fn, ms), []) Never recreate inside the render body.
React: cleanup useEffect(() => () => debounced.cancel(), []) Prevents calls firing after unmount.
// the whole pattern, copy-paste ready
function debounce(fn, delayMs) {
  let timeoutId;
  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  }
  debounced.cancel = () => clearTimeout(timeoutId);
  return debounced;
}

function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
  let lastRun = 0;
  let timeoutId = null;
  let lastArgs = null;

  function throttled(...args) {
    const now = Date.now();
    const remaining = intervalMs - (now - lastRun);
    lastArgs = args;

    if (remaining <= 0) {
      if (leading || lastRun !== 0) {
        lastRun = now;
        fn.apply(this, args);
      }
    } else if (trailing && !timeoutId) {
      timeoutId = setTimeout(() => {
        timeoutId = null;
        lastRun = Date.now();
        fn.apply(this, lastArgs);
      }, remaining);
    }
  }
  throttled.cancel = () => {
    clearTimeout(timeoutId);
    timeoutId = null;
  };
  return throttled;
}
Enter fullscreen mode Exit fullscreen mode

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Key takeaways

  • Debounce waits for quiet and runs once at the end; throttle runs on a fixed schedule no matter how long the activity continues.
  • Every event still reaches the wrapper — what changes is how often the real work behind it is allowed to run.
  • Use debounce for "what's the final value" (search, autosave); use throttle (or requestAnimationFrame) for "keep me updated while this continues" (scroll, resize, drag).
  • Both need cancel() wired into cleanup — an uncancelled debounce or throttle is a call waiting to fire on a component that no longer exists.
  • Create the wrapped function once, not on every render.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

That search box from the opening paragraph needs exactly one line changed — wrap the handler in debounce(fetchResults, 300) — and the ten wasted requests become one, fired the moment the user actually stops typing. Which of your own event handlers is still firing ten times more than it needs to?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.

The distinction between temporal suppression and execution scheduling is explained very well here. One advanced issue I would add is that debouncing network calls should be coupled with request cancellation and response versioning. A debounced fetch can still leave an earlier request in flight, allowing an out of order response to overwrite newer state. AbortController plus a monotonically increasing request sequence solves that cleanly.

For React, I also prefer separating event rate limiting from state synchronization. Keep the debounced callback stable, store mutable dependencies in refs, and treat cleanup as part of the lifecycle contract.

For high frequency visual events, requestAnimationFrame is particularly important because JavaScript execution should align with the browser rendering pipeline rather than an arbitrary timer interval.

A great tutorial overall. The next interesting layer would be benchmarking these strategies under real event pressure with PerformanceObserver and flame charts.