Frontend

Debounce vs Throttle in JavaScript (With Code You Can Paste)

Debounce and throttle both limit how often a function runs, but they fix different UX problems. Here are the implementations I use and the call sites that broke when I mixed them up.

What you will learn

  • Debounce waits for quiet; throttle paces a storm
  • Search inputs → debounce; scroll/resize → throttle
  • Always cancel timers on unmount
  • Abort in-flight fetches when debounced queries change
  • Do not recreate debounced functions every React render

The difference in one sentence each

Debounce: wait until events stop arriving, then run once. Throttle: run at most once per time window while events keep arriving. Search-as-you-type wants debounce. Scroll position syncing often wants throttle.

I once debounced a scroll handler for a reading progress bar. The bar jumped only after the user stopped scrolling—useless. Switching to throttle made it track continuously without firing on every pixel.

Both techniques are about respecting the main thread and the network. They are not interchangeable spices you sprinkle until jank disappears.

Think in terms of the signal: bursty discrete intent (typing a query) versus continuous streams (scroll position). That framing picks the tool before you debate millisecond constants.

If you only remember the scroll progress mistake and the search typeahead pattern, you already outrank most copy-paste snippets that treat both tools as synonyms.

Debounce you can trust

Trailing debounce is the common case for inputs. Leading+trailing variants exist; start with trailing. Always cancel on unmount in SPA components or you will setState on dead trees.

Pick wait times from UX, not habit. 300ms feels right for search. 600ms can feel sluggish. 100ms may still hammer an expensive endpoint. Measure request rates with a real typer, not a synthetic loop.

Leading debounce is useful when you want an immediate reaction then silence—rare for search, more common for “save draft” buttons that should fire once on first click then ignore chatter.

Expose cancel() and flush() if your UX needs “run now on blur.” Forms often debounce input but should flush pending saves when the field loses focus.

function debounce(fn, wait = 300) {
  let timer;
  function debounced(...args) {
    const context = this;
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(context, args), wait);
  }
  debounced.cancel = () => clearTimeout(timer);
  return debounced;
}

// Usage: typeahead
const onQuery = debounce(async (q) => {
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
  renderResults(await res.json());
}, 300);

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

Throttle for continuous signals

Throttle guarantees periodic updates during a burst. I use a trailing call so the last event is not lost—important for resize and scroll end positions.

For pointer-move annotations on a canvas, throttle to animation-frame cadence or ~16–32ms. Throttling to 200ms makes drawing feel drunk.

requestAnimationFrame is sometimes a better throttle for visual updates. It aligns work to paint. For network or analytics, time-based throttle remains clearer.

Document whether your throttle is leading, trailing, or both. Callers who assume leading behavior will swear your helper is broken when the first event waits for the window.

function throttle(fn, wait = 100) {
  let last = 0;
  let timer;
  let lastArgs;
  let lastContext;

  function invoke() {
    last = Date.now();
    timer = null;
    fn.apply(lastContext, lastArgs);
  }

  function throttled(...args) {
    lastArgs = args;
    lastContext = this;
    const now = Date.now();
    const remaining = wait - (now - last);

    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      invoke();
    } else if (!timer) {
      timer = setTimeout(invoke, remaining);
    }
  }

  throttled.cancel = () => {
    clearTimeout(timer);
    timer = null;
  };
  return throttled;
}

window.addEventListener(
  "scroll",
  throttle(() => {
    progress.style.width = `${scrollPercent()}%`;
  }, 100),
  { passive: true }
);

Call-site cheat sheet

Autocomplete / server search: debounce 200–400ms. Window resize layout recalculation: throttle ~100–150ms. Button spam prevention: often a simple disabled flag or leading throttle of 1s—not debounce, which would delay the first click.

Analytics “track mouse moves”: throttle heavily. Form save indicators: debounce. Mixing these up creates APIs that feel laggy or backends that melt.

Infinite scroll fetch triggers: throttle or a sentinel IntersectionObserver. Debouncing scroll for infinite load feels broken because users pause mid-page and nothing happens until they stop.

Server-side rate limits do not replace client debounce. Both belong. Debounce protects UX and your bill; rate limits protect the service from clients that ignore good manners.

  • Debounce → “run after things calm down”
  • Throttle → “run steadily during the storm”
  • Cancel timers on component unmount
  • Prefer passive listeners for scroll/touch when you do not preventDefault

React notes without a library lecture

Creating debounce inside render re-creates timers every paint—store the debounced function in useRef or useMemo with stable deps. Better: debounce in an event handler factory created once.

For data fetching, pair debounce with abort controllers so an older response cannot overwrite a newer query. That bug looked like “search flickers wrong results” until we aborted in-flight fetches.

useDeferredValue can replace some debounce for pure UI lag. It does not replace debounce for network calls—you still need to gate fetches.

In tests, fake timers. Debounce bugs appear as intermittent failures when wall clocks vary. I assert call counts under jest.useFakeTimers or equivalent.

import { useEffect, useMemo, useState } from "react";

function useDebouncedValue(value, wait = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), wait);
    return () => clearTimeout(id);
  }, [value, wait]);
  return debounced;
}

// In component:
// const debouncedQuery = useDebouncedValue(query, 300);
// useEffect(() => { fetchSearch(debouncedQuery); }, [debouncedQuery]);

Libraries vs hand-rolled

Lodash debounce/throttle are fine and well-tested. For a design system I still paste a 20-line helper to avoid pulling lodash into a critical path bundle. Either way, understand leading/trailing options before arguing about packages.

Measure: if your input handler triggers network, debounce is a product requirement, not a micro-optimization.

Write two unit tests: rapid calls only invoke once for debounce after wait, and throttle invokes immediately then respects the window. Those tests catch the off-by-one bugs that make UX feel haunted.

Bundle size arguments aside, copying a well-understood 20-line helper into a repo is fine. Just put it in one module so you do not end up with five slightly different throttles.

Whatever you choose, keep one implementation per app. Five slightly different throttles guarantee one of them forgets to cancel on unmount.

Real incident: search stampede from the browser

We shipped typeahead without debounce on a Black Friday microsite. Each keypress hit Elasticsearch. A burst of traffic plus fast typers multiplied into a nasty queue. Adding 250ms debounce and abortable fetches cut search QPS by roughly 70% with no complaints about lag.

The complementary fix was a 429-aware client that backed off. Client-side pacing and server-side limits together kept the search cluster boring—which is what you want on sale day.

  • Debounce before you scale the search cluster
  • Abort stale requests when the query changes
  • Handle 429 with backoff even on first-party UIs

Key takeaways

  • Debounce waits for quiet; throttle paces a storm
  • Search inputs → debounce; scroll/resize → throttle
  • Always cancel timers on unmount
  • Abort in-flight fetches when debounced queries change
  • Do not recreate debounced functions every React render

About the author

Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.

More about Ram · Contact

← SQL vs NoSQL: A Decision Guide From Real P… Next: WebP and AVIF Image Optimization That Actu… →