Debounce vs Throttle in JavaScript: Examples, React Hook and When to Use Each

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
5 min read
Published Dec 5, 2024
Updated Oct 9, 2026
Learn How to Use Debouncing and Throttling in JavaScript for Better Performance Banner Image
12 min read
824 words
16 sections12 code blocks

Debounce waits until calls stop for a set time, then runs once. Throttle runs at most once per time window while calls keep coming. Use debounce for search boxes, autosave and resize-end; use throttle for scroll, mouse move and progress tracking. Both stop a function from running hundreds of times a second.

Tested with: Chromium 156 and WebKit 27.2 (Safari's engine) via Playwright, and React 19.3 under StrictMode, October 2026. The timings below are from those runs.

Debounce vs Throttle at a Glance

DebounceThrottle
RunsOnce, after calls stop for the wait timeAt most once per wait time while calls continue
10 calls, 50 ms apart, wait 300 ms1 run, with the last value, 300 ms after the last call3 runs: at 0, 300 and 600 ms, with values 1, 6 and 10
Best forSearch input, autosave, form validation, resize end, double-click protectionScroll, mousemove, drag, window resize while resizing, rate-limited APIs
RiskNever runs if calls never stopSkips intermediate values

Debounce in JavaScript

JavaScript
// Run fn once calls stop for `wait` ms. With { leading: true },
// run on the first call of a burst instead of after it.
export function debounce(fn, wait, { leading = false } = {}) {
  let timer = null;
  let lastArgs;
  let lastThis;

  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    const callNow = leading && timer === null;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(lastThis, lastArgs);
    }, wait);
    if (callNow) fn.apply(lastThis, lastArgs);
  }

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

  // Run the pending call right away (e.g. before the page unloads)
  debounced.flush = () => {
    if (timer === null || leading) return;
    debounced.cancel();
    fn.apply(lastThis, lastArgs);
  };

  return debounced;
}
JavaScript
import { debounce } from "./debounce.js";

const input = document.querySelector("#search");

const search = debounce((query) => {
  console.log("Searching for", query); // runs once typing pauses for 300 ms
}, 300);

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

In our test, 10 calls made 50 ms apart produced one run, with the value from the 10th call, about 300 ms after it.

  • The function keeps this and the latest arguments, so it works as an object method and as an event handler.
  • Under the hood it's just clearTimeout plus setTimeout: each call cancels the previous timer. Our guide to clearing timeouts explains the timer side.

Leading debounce: run on the first call

JavaScript
// Run on the first click, ignore repeat clicks until 1 s of quiet
const submitOrder = debounce(() => placeOrder(), 1000, { leading: true });
button.addEventListener("click", submitOrder);

With { leading: true }, the first call of a burst runs immediately and the rest are ignored until calls stop. In our test, five quick calls ran only the first; a call after the quiet period ran again right away. This is the pattern for "don't submit the form twice".

cancel() and flush()

JavaScript
const saveDraft = debounce((text) => api.save(text), 2000);
editor.addEventListener("input", () => saveDraft(editor.value));

// Don't lose the last edit when the tab is hidden or closed
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") saveDraft.flush();
});

cancel() drops the pending call (we checked it never ran); flush() runs it now. Saving on visibilitychange is more reliable than beforeunload, especially on mobile; see detecting tab changes.

Throttle in JavaScript

JavaScript
// Run fn at most once every `wait` ms: immediately on the first call,
// then once more at the end with the latest arguments.
export function throttle(fn, wait) {
  let last = 0;
  let timer = null;
  let lastArgs;
  let lastThis;

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

  function throttled(...args) {
    lastArgs = args;
    lastThis = this;
    const remaining = wait - (Date.now() - last);
    if (remaining <= 0) {
      clearTimeout(timer);
      run();
    } else if (timer === null) {
      timer = setTimeout(run, remaining);
    }
  }

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

  return throttled;
}
JavaScript
import { throttle } from "./throttle.js";

const reportScroll = throttle(() => {
  analytics.track("scroll", { y: window.scrollY });
}, 200);

window.addEventListener("scroll", reportScroll, { passive: true });

An older version of this article throttled with a timestamp check and nothing else, so the final call was simply dropped. We called a 200 ms throttle every 20 ms for one second. Chromium made 49 calls and WebKit 33 (its timers ran slower under load), and both ran the function 6 times: at 0 ms and roughly every 200 ms after, with the last run using the final value. That trailing run matters: without it, the last scroll position or input would be lost.

{ passive: true } tells the browser your scroll listener won't call preventDefault(), so scrolling stays smooth.

requestAnimationFrame for Visual Updates

If the throttled function only changes what's on screen, sync it to the display instead of picking a number of milliseconds:

JavaScript
// For visual updates on scroll/resize: at most once per animation frame
export function rafThrottle(fn) {
  let frame = null;
  let lastArgs;
  return function (...args) {
    lastArgs = args;
    if (frame !== null) return;
    frame = requestAnimationFrame(() => {
      frame = null;
      fn(...lastArgs);
    });
  };
}
JavaScript
const updateProgressBar = rafThrottle(() => {
  const max = document.documentElement.scrollHeight - innerHeight;
  bar.style.transform = `scaleX(${scrollY / max})`;
});
window.addEventListener("scroll", updateProgressBar, { passive: true });

In our test, 212 calls in half a second ran the function 32 times in Chromium, once per frame (33 frames). Browsers pause animation frames in background tabs, so use a time-based throttle for analytics or network work instead.

Debouncing in React

Creating the debounced function inside the component body is the most common bug: every render (every keystroke) makes a new function with a new timer, so every call goes through:

JSX
// ❌ A new debounced function on every render: nothing is debounced
function Search() {
  const [query, setQuery] = useState("");
  const search = debounce((q) => fetchResults(q), 300);
  return <input value={query} onChange={(e) => { setQuery(e.target.value); search(e.target.value); }} />;
}

For search, debounce the value instead of the function. This hook updates only after the value stops changing:

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

// Returns `value`, but only after it has stopped changing for `delay` ms
export function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // a new keystroke cancels the old timer
  }, [value, delay]);

  return debounced;
}
JSX
import { useEffect, useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue.jsx";

export function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query.trim(), 300);
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      return;
    }
    const controller = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (!controller.signal.aborted) console.error(err);
      });
    return () => controller.abort(); // drop answers to older queries
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />
      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );
}

What happened in our React test (StrictMode, fake API with 150 ms responses):

  • Typing "r", "re", "rea", "reac", "react" 80 ms apart sent one request, for "react".
  • Changing the query to "vue" aborted the older request through the effect cleanup, and the list showed only the "vue" results, so a slow old response can't overwrite a newer one.
  • Typing and unmounting within 300 ms sent no request: the cleanup cleared the timer.

If you need a debounced callback (not a value), create it once with useMemo or useRef and call .cancel() in an effect cleanup. More on cleanups in why useEffect runs twice.

Using Lodash Instead

JavaScript
import debounce from "lodash/debounce";
import throttle from "lodash/throttle";

const save = debounce(saveDraft, 2000, { maxWait: 10000 }); // save at least every 10 s
const onScroll = throttle(track, 200, { trailing: false });

Lodash's versions add options like maxWait (guarantee a run during endless input) and trailing: false. If you already ship Lodash, use them; otherwise the 30-line versions above cover most apps without a dependency.

Which One Should I Use?

SituationUseTypical wait
Search-as-you-type / autocompleteDebounce (value)250–400 ms
Autosave a draftDebounce + flush on hide1–2 s
Prevent double submitLeading debounce1 s
Validate a field while typingDebounce300–500 ms
Track scroll depth or send analyticsThrottle200–1000 ms
Sticky header, progress bar, parallaxrequestAnimationFrame1 frame
Recalculate layout after resizeDebounce150–250 ms
Send cursor position over a WebSocketThrottle50–100 ms

For real search UIs built on these ideas, see our dynamic search bar and searchable dropdown guides; for live cursors and chat, see JavaScript WebSockets.

Common Mistakes

MistakeWhat happensFix
Creating the debounced function inside a React componentNew timer every render; nothing is debouncedDebounce the value, or create it once (useMemo/useRef)
Throttle without a trailing callThe final position or value is lostRun once more at the end with the latest arguments
No cleanup on unmountA late call updates an unmounted componentClear the timer / call cancel() in the cleanup
Debouncing a fetch but not cancelling itAn old slow response overwrites a new oneAbort the previous request (AbortController)
Debouncing scroll animationsNothing moves until scrolling stopsThrottle with requestAnimationFrame

FAQs

What is the difference between debounce and throttle?

Debounce runs the function once after the calls stop. Throttle runs it at regular intervals while the calls continue. A search box should wait until you stop typing (debounce); a scroll indicator should keep updating while you scroll (throttle).

How do I debounce in JavaScript without Lodash?

Keep a timer ID, clear it on every call and start a new setTimeout. The debounce() function above does this and adds a leading option, cancel() and flush().

250 to 400 ms. Shorter sends more requests; longer feels laggy. 300 ms is a common default.

How do I debounce in React?

Debounce the value with a hook like useDebouncedValue, then fetch in an effect that depends on the debounced value. Don't create a debounced function in the render body.

Is requestAnimationFrame better than throttle?

For visual updates, yes: it runs once per frame, in sync with the screen. For network calls or analytics, use a time-based throttle, because animation frames stop in background tabs.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About Muhaymin Bin Mehmood

Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.