Debounce vs Throttle: Wait for Quiet, or Keep a Steady Pace

4 min read

Some events fire a lot. Typing “javascript” fires 10 input events. One second of scrolling can fire 60 scroll events. You rarely need to react to every one of them: nobody wants 10 API requests for one search.

Debounce and throttle are two ways to say “not every time”. Here is the whole difference in one picture:

Each thin line is an event, each dot is a run (wait: 500 ms). Debounce runs once, after the events stop. Throttle runs at a steady pace while they keep coming.

Debounce: Wait Until Things Calm Down#

Think of an elevator. When someone walks in, the doors wait a few seconds in case someone else is coming. Each new person restarts the wait. The doors close only when nobody has come in for a while: one trip, however many people got in.

That’s debounce: every new event restarts the timer, and the function runs only after a quiet period.

function debounce(fn, wait) {
  let timer;

  return (...args) => {
    clearTimeout(timer); // Someone walked in: cancel the countdown...
    timer = setTimeout(() => fn(...args), wait); // ...and start a new one.
  };
}

The name comes from electronics. When you press a physical button, its metal contacts bounce for a few milliseconds, so one press looks like several. “Debouncing” means waiting for the signal to settle before counting the press.

Try it. Move your mouse over the box and watch the lanes:

Move your mouse here, drag a finger sideways, or focus this box and hold a key
Events0
Debounced0
The dashed line is now, the outlined circle is the planned run. Every event pushes it ahead again. Stop for the full wait and it becomes a dot.

Keep moving and it never runs. That’s not a bug: debounce only cares about the moment you stop.

Throttle: Keep a Steady Pace#

Think of a kid in the back seat asking “Are we there yet?” every five seconds. A tired parent answers the first time, then at most once a minute, no matter how often the kid asks. If the kid asked during that minute, the parent answers once more when it’s up, so the last question isn’t ignored.

That’s throttle: run right away, ignore calls during a cooldown, then run once more with the latest call if one came in.

function throttle(fn, wait) {
  let lastRun = 0;
  let timer;

  return (...args) => {
    clearTimeout(timer);
    const remaining = wait - (Date.now() - lastRun);

    if (remaining <= 0) {
      // Cooldown is over: run now.
      lastRun = Date.now();
      fn(...args);
    } else {
      // Still cooling down: run the latest call when the cooldown ends.
      timer = setTimeout(() => {
        lastRun = Date.now();
        fn(...args);
      }, remaining);
    }
  };
}

The name comes from cars: the throttle controls how much fuel reaches the engine. Think of a speed limiter: press the pedal as hard as you want, the car won’t go faster than its limit.

Simpler versions online often skip the else part and lose the last call. If someone scrolls to the bottom during a cooldown, you never hear about it.

Move your mouse here, drag a finger sideways, or focus this box and hold a key
Events0
Throttled0
The shaded bar after each dot is the cooldown. Events during it are ignored, except the last one, which runs when the cooldown ends.

Side by Side#

Move in short bursts, then in one long move. Try a longer wait with the slider.

Move your mouse here, drag a finger sideways, or focus this box and hold a key
Events0
Debounced0
Throttled0

If you remember one thing: do you need the final value, or updates along the way?

Final value: debounce Updates along the way: throttle
Search once they stop typing Scroll position while they scroll
Autosave once they stop editing Infinite scroll: are we near the bottom yet?
“Is this username taken?” once they pause Drag and drop, live cursors
Layout once they stop resizing the window Progress updates and analytics while they read
const search = debounce(async (query) => {
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  showResults(await response.json());
}, 300);

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

Type a word at your normal speed:

Without debounce

0 requests

    With debounce (300 ms)

    0 requests

      Between 200 and 500 ms usually feels right. Shorter, and you still search mid-word. Longer, and the search feels slow.

      Debounce doesn’t fix one thing: responses can arrive out of order. If “ber” is slow and “berlin” is fast, the “ber” results can land last and overwrite the right ones. Cancel the old request when you start a new one:

      let controller;
      
      const search = debounce(async (query) => {
        controller?.abort(); // Cancel the request that is still running, if any.
        controller = new AbortController();
      
        try {
          const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
          showResults(await response.json());
        } catch (error) {
          if (error.name !== "AbortError") throw error;
        }
      }, 300);

      Real Example: Scroll Tracking for Analytics#

      To learn how far people read, you send the scroll depth to your analytics tool. That’s a job for throttle: you want updates while they scroll. With debounce, a slow, steady reader would send nothing until the very end.

      const trackScrollDepth = throttle(() => {
        const scrolled = window.scrollY + window.innerHeight;
        const percent = Math.round((scrolled / document.documentElement.scrollHeight) * 100);
        analytics.track("scroll_depth", { percent });
      }, 1000);
      
      window.addEventListener("scroll", trackScrollDepth, { passive: true });

      Scroll the article above.

      Without throttle

      0 track() calls

        With throttle (1 s)

        0 track() calls

          Tracking search terms is the opposite case. You want “berlin”, not “b”, “be”, “ber”, and so on, so use debounce with a longer wait:

          const trackSearch = debounce((query) => analytics.track("search", { query }), 1000);

          One catch: if someone closes the tab during the wait, the event is lost. For events you can’t lose, send what’s pending on pagehide. Lodash’s debounce has a flush() method for this.

          A Mistake That’s Easy to Make#

          The returned function remembers its timer between calls. Create a new one every time and nothing is debounced:

          // ❌ A new debounced function on every event. Every call goes through.
          input.addEventListener("input", (event) => debounce(search, 300)(event.target.value));
          
          // ✅ Create it once, call it many times.
          const debouncedSearch = debounce(search, 300);
          input.addEventListener("input", (event) => debouncedSearch(event.target.value));

          In React, the same happens if you call debounce() in a component body: every render makes a new one. Create it once with useMemo or useRef.

          Should You Write Your Own?#

          The versions here are fine for most cases. Lodash adds cancel(), flush(), and options like leading and maxWait. Fun fact: lodash’s throttle is just debounce with maxWait set to the wait. A debounce that refuses to wait forever is a throttle.

          Node.js Now Has Them Built In#

          Since Node.js 24.22.0 and 26.10.0, node:util has both. Only one of them works like the versions in this post.

          util.debounce is the same idea, with leading, cancel(), and flush() built in. It returns a promise, and all calls in the same window get the same result. A file watcher is a good fit, since saving one file can fire several change events:

          import { watch } from "node:fs";
          import { debounce } from "node:util";
          
          const rebuild = debounce(() => console.log("Rebuilding..."), 200);
          watch("./src", { recursive: true }, rebuild);

          util.throttle is a different idea. It’s a rate limiter: at most N calls start per interval, and the rest wait in line. Nothing is dropped:

          Five quick calls. Our throttle (500 ms) runs the first and the latest, and drops the rest. util.throttle(fn, 2, 500) runs all of them, two per half second, in order.

          That’s perfect for staying under an API’s “2 requests per second” limit:

          import { throttle } from "node:util";
          
          const getUser = throttle(async (id) => {
            const response = await fetch(`https://api.example.com/users/${id}`);
            return response.json();
          }, 2, 1000);
          
          const users = await Promise.all(userIds.map((id) => getUser(id)));

          But it’s the wrong tool for scroll or mouse events: every event would be queued and run, just later. { overflow: "drop" } rejects the extra calls instead, but then the last call, the one with the final position, is dropped too.

          To Sum Up#

          • Debounce is the elevator door: it waits until people stop coming in, then goes once.
          • Throttle is the tired parent: it answers at most once a minute, however often you ask.
          • Need the final value? Debounce. Need updates along the way? Throttle.
          • Create the debounced or throttled function once, and reuse it.
          • In Node.js, util.debounce is a regular debounce, but util.throttle is a rate limiter that queues calls.

          ← All posts