Skip to content
JavaScript

Debounce vs Throttle in JavaScript: When to Use Each

By The EbookWale Team · Updated July 27, 2026 · 14 min read

Debounce waits for the input to stop; throttle runs at a fixed maximum rate. Which to choose, plus the React, ordering and teardown bugs that follow.

Debounce waits until the events stop arriving and then runs your function once; throttle runs it right away and then at most once per interval while the events keep coming. Debounce collapses a burst into a single call at the end. Throttle samples the burst at a steady rate.

That much you can get anywhere. The part that costs you an afternoon is everything after it: the debounced handler that never debounces because a re-render replaced it, the trailing call that fires into a component that no longer exists, and the debounced search box that sends fewer requests and still paints the wrong results. So the choice comes first here, then each way that choice breaks in real code.

The short version: if your handler only needs the final state, debounce it; if it needs the intermediate states, throttle it.

Which one do you need? One question decides it

Ask what your handler actually does with the events it receives.

  • Only the last value matters? Debounce. Autosaving a draft, firing a search, validating a field: the intermediate keystrokes are worthless, and acting on them is pure waste.
  • The intermediate values matter? Throttle. A scroll position readout, a drag preview, a progress meter: waiting for the user to stop moving would mean showing nothing at all while they move.
DebounceThrottle
Runsonce, after the input stopsat most once per interval, while input continues
During a continuous burstnever, unless you set a maxWaitsteadily
Dropsevery event but the lastevery event but one per window
Choose whenyou need the final stateyou need the intermediate states
Typical usessearch-as-you-type, autosave, window resizescroll, mousemove, drag, rate-limited APIs

A camera makes the same decision. Debounce is a long exposure that writes one frame once the subject has stopped moving. Throttle is shooting at a fixed rate and keeping every nth frame. One gives you the final state, the other gives you the motion, and the frames they throw away are different.

inputdebouncethrottleeach cell is 50ms; wait is 100ms for both
Same input, two behaviours: debounce merges the burst and runs late, throttle runs at once and caps the rate.

Read that for what is dropped versus what is merely delayed. Debounce delays everything and merges all but one event of the burst. Throttle drops nothing at the leading edge, then silently discards whatever arrives during each cooldown.

Write both from scratch

Debounce is a timer you keep restarting. The timer handle lives in a closure, which is the only reason the returned function can remember it between calls.

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);                          // cancel the pending trailing call
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

const save = debounce((draft) => console.log('saved:', draft), 50);

save('h');        // three keystrokes, all faster than the 50ms wait
save('he');
save('hel');
saved: hel

Throttle is a stopwatch rather than a timer. This version takes its clock as an argument so the behaviour is deterministic and testable; in application code you still just call throttle(fn, 100).

// `now` is injected so the behaviour is testable; in real code you call throttle(fn, 100).
function throttle(fn, wait, now = Date.now) {
  let last = -Infinity;
  return function (...args) {
    const t = now();
    if (t - last < wait) return;                  // inside the cooldown: drop it
    last = t;
    fn.apply(this, args);
  };
}

let clock = 0;
const onScroll = throttle((y) => console.log(`t=${clock} ran with y=${y}`), 100, () => clock);

// one scroll gesture: six events, the last of them at t=160
for (const [t, y] of [[0, 0], [20, 40], [60, 90], [110, 150], [130, 180], [160, 240]]) {
  clock = t;
  onScroll(y);
}
t=0 ran with y=0
t=110 ran with y=150

Notice what that output does not contain. The event at t=130 is dropped and never revisited, so the position y=180 is simply gone. Then the gesture’s last event, at t=160, lands 50ms into the cooldown that opened at t=110, so it is dropped too. The last line printed is y=150; the user has stopped scrolling at y=240. Nothing will ever correct that, because a leading-edge throttle has no way to know the burst is over.

That is the concrete reason libraries expose a trailing option: it schedules one final call at the end of the window, so a burst that stops inside a cooldown still gets its last value out. Ship lodash.debounce and lodash.throttle in production rather than the ten-line version; write the ten-line version once so you know what the library is doing for you.

Leading or trailing edge? A UX decision, not a footnote

Both functions can fire at the start of a window, at the end, or both.

  • Leading runs on the first event, then ignores the rest of the window. Use it when the user is waiting to see something happen: a button, a menu toggle, a like. A 300ms trailing debounce on a button makes the interface feel broken for 300ms.
  • Trailing runs after the quiet period. Use it when the work is expensive and the user is still deciding: search, autosave, validation.

Every serious library makes both configurable, and the defaults are not the same across libraries or across the two functions, so check the docs for the version you have rather than assuming.

The hybrid worth knowing is maxWait: a trailing debounce that promises to run at least every N milliseconds even if the burst never ends. Without it, someone who types continuously for ten seconds triggers nothing for ten seconds, because every keystroke restarts the wait. Set a debounce’s maxWait close to its wait and the observable behaviour converges on a throttle, which is why these two feel like ends of one dial rather than opposites.

⚠️ GOTCHA: Client-side throttling shares a word with server-side rate limiting and is a different thing. Throttling your handler protects your own main thread and your own quota. It gives the server no guarantee at all, because nothing stops a second tab, a retry, or another user.

Why is your debounce not debouncing?

This is the most common failure of the two, and it produces no error at all.

function SearchBox({ onSearch }) {
  // BROKEN: a new debounced function, with a new empty timer, on every render.
  const handleChange = debounce((e) => onSearch(e.target.value), 300);
  return <input onChange={handleChange} />;
}

Every keystroke re-renders, every re-render calls debounce() again, and the new closure gets its own timer variable set to undefined. There is nothing to cancel, so every keystroke schedules a call and all of them fire. You have bought the original behaviour plus a 300ms delay.

The fix is to create the debounced function exactly once, and keep the callback that changes somewhere stable:

function SearchBox({ onSearch }) {
  const onSearchRef = useRef(onSearch);
  useEffect(() => { onSearchRef.current = onSearch; });

  const handleChange = useMemo(
    () => debounce((value) => onSearchRef.current(value), 300),
    [],                                    // created once, so the timer survives renders
  );
  useEffect(() => handleChange.cancel, [handleChange]);

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

Two details carry their weight. The ref keeps onSearch fresh without putting it in the dependency array, which would rebuild the debounced function and reintroduce the bug. And passing e.target.value instead of the event means the handler is not clinging to an event object 300ms after the fact.

What happens to the call that fires after teardown?

A pending trailing call does not know you navigated away. It fires, sets state on an unmounted component, or writes to a DOM node that has been removed. So a debounced function needs a cancel, and every teardown path owes it one.

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

const save = debounce(() => console.log('saved'), 20);
save();
save.cancel();
console.log('torn down before the timer fired');
torn down before the timer fired

Tear the listener down too. addEventListener accepts an AbortSignal, so one abort() detaches the listener without your having to keep a reference to the exact function you attached:

const controller = new AbortController();
window.addEventListener('scroll', onScroll, { signal: controller.signal });
// later, one call removes the listener:
controller.abort();
🔑 REMEMBER: A debounced function with no cancel is a leak with a delay on it. Anywhere you create one, you owe it a teardown.

Why does the debounced search box still show the wrong results?

Because debouncing controls when you send, and nothing about when responses arrive. Send two requests and the slower earlier one can land last and overwrite the newer one.

// A stand-in for the network: the earlier query happens to be the slow one.
const fakeSearch = (q, ms) =>
  new Promise((resolve) => setTimeout(() => resolve(`results for "${q}"`), ms));

fakeSearch('ja', 60).then((r) => console.log('painted:', r));
fakeSearch('java', 10).then((r) => console.log('painted:', r));
painted: results for "java"
painted: results for "ja"

The user typed java and is looking at results for ja. A longer wait does not fix this, it only makes it rarer. The fix is a sequence guard that discards anything stale, or AbortController to cancel the previous request outright.

const fakeSearch = (q, ms) =>
  new Promise((resolve) => setTimeout(() => resolve(`results for "${q}"`), ms));

let latest = 0;
function search(q, ms) {
  const id = ++latest;                       // claim a sequence number
  return fakeSearch(q, ms).then((r) => {
    if (id !== latest) { console.log('discarded stale:', r); return; }
    console.log('painted:', r);
  });
}

search('ja', 60);
search('java', 10);
painted: results for "java"
discarded stale: results for "ja"

AbortController is the better default for real requests, because it also stops work the network and the server are still doing:

let inFlight;

const runSearch = debounce((query) => {
  inFlight?.abort();                       // cancel the previous request outright
  inFlight = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: inFlight.signal })
    .then((res) => res.json())
    .then(render)
    .catch((err) => { if (err.name !== 'AbortError') throw err; });
}, 300);

Note the catch. An aborted fetch rejects, and swallowing every rejection here would hide real network failures along with the intentional cancellations.

When is the answer neither one?

Reach for a purpose-built API before you reach for a timer.

  • Anything that reads or writes layout belongs in requestAnimationFrame, which the browser calls before the next repaint, so the work lands aligned to a frame. A time-based throttle has no idea when a frame is due, so it either does work the browser then discards or misses the frame it was aiming at.
  • { passive: true } belongs on the events that cause scrolling, meaning wheel and touchmove, not on scroll itself, which is not cancelable and so gains nothing from the flag. Passive promises the browser you will not call preventDefault(), so scrolling need not wait on your handler. On Window, Document and document.body most browsers already default those events to passive; Safari does not.
  • “Is this element visible yet?” is IntersectionObserver, and “has this element resized?” is ResizeObserver. Both are widely available, and both let the browser do the geometry bookkeeping instead of your code calling getBoundingClientRect() on every scroll event. Infinite scroll and lazy loading in particular want an IntersectionObserver on a sentinel element, not a throttled scroll handler.

What about this, the arguments, and the return value?

Two of the three are already handled above: fn.apply(this, args) forwards both the receiver and the arguments, which is why these implementations use function rather than an arrow. An arrow would capture the wrong this and quietly break any method you debounce.

The return value is the genuinely awkward one. A debounced function has nothing to return, because at call time the real function has not run. If callers need a promise, you have to decide what happens to the calls you dropped, because leaving their resolvers unsettled leaks them along with everything awaiting them.

function debouncePromise(fn, wait) {
  let timer;
  let waiting = [];
  return function (...args) {
    return new Promise((resolve) => {
      waiting.push(resolve);
      clearTimeout(timer);
      timer = setTimeout(() => {
        const settle = waiting;
        waiting = [];
        const value = fn(...args);
        for (const resolve of settle) resolve(value);   // nobody is left hanging
      }, wait);
    });
  };
}

const search = debouncePromise((q) => `results for "${q}"`, 20);

Promise.all([search('j'), search('ja'), search('jav')])
  .then((results) => console.log(results.join(' | ')));
results for "jav" | results for "jav" | results for "jav"

Every caller settles, all of them on the winner’s result. That is a deliberate choice, and rejecting the dropped calls instead would be just as defensible; what is not defensible is silently never settling them. If that reasoning is unfamiliar, promises and async/await is the prerequisite.

How do you test a debounce without sleeping?

A test that waits 300ms for real is slow, and it fails on a loaded CI box for reasons that have nothing to do with your code. Advance a fake clock instead.

import { describe, expect, it, vi } from 'vitest';

describe('debounce', () => {
  it('collapses a burst into one trailing call', () => {
    vi.useFakeTimers();
    const spy = vi.fn();
    const debounced = debounce(spy, 300);

    debounced('a');
    debounced('b');
    vi.advanceTimersByTime(299);
    expect(spy).not.toHaveBeenCalled();       // still inside the wait

    vi.advanceTimersByTime(1);
    expect(spy).toHaveBeenCalledTimes(1);
    expect(spy).toHaveBeenCalledWith('b');    // the last arguments win
    vi.useRealTimers();
  });
});

The throttle written earlier needs no fake timers at all, because its clock is a parameter: set clock, call it, assert. Injecting the clock costs one argument and buys a fully synchronous test.

The same reasoning explains why you should never assert on real elapsed time. setTimeout queues a task, and the delay you hand it is a floor rather than an appointment. A busy main thread pushes it later, deeply nested timeouts are clamped to a minimum, and browsers throttle timers aggressively in background tabs by amounts that differ per browser. Your 300ms debounce firing at 340ms is conforming behaviour, not a bug.

Common mistakes

  • Creating the debounced function inside a render, an event handler, or a loop. It has to outlive the events it is meant to collapse.
  • Debouncing a click. Users read delay as breakage. Use a leading edge, or disable the button while the request is in flight.
  • Assuming fewer requests means correct results. Ordering is a separate problem needing a separate fix.
  • Throttling with a leading edge only on a handler that has to reflect a final position.
  • Forgetting cancel on unmount, which is one of the quieter entries in our roundup of common JavaScript mistakes.
  • Reaching for a timer where an observer exists. IntersectionObserver is more accurate and cheaper than any throttle you can write.

Where the books fit

Debounce and throttle sit exactly where the JavaScript roadmap turns from syntax into behaviour: closures, timers, and the asynchronous model underneath both. If those foundations are still shaky, JavaScript in One Month builds them in order. JavaScript in Three Months, our job-ready tier, is where the timing model and the interview versions of these questions get worked through properly.

Everything in the second half of this guide, cancellation, request ordering, frame-aligned work, and designing a function whose callers hold promises you may have to drop, is senior-level API design wearing a small utility as a disguise. That is the ground JavaScript for Staff Engineers covers.

Write the ten-line version once so you understand it, then use your library’s, and spend the time you saved on the cancellation path instead.

Frequently asked questions

What is the difference between debounce and throttle?

Debounce waits until the events stop arriving and then runs your function once. Throttle lets the function run and then blocks it until a fixed interval has passed, so it keeps running steadily during a burst. Debounce gives you the final state; throttle gives you samples of the intermediate states.

Should I use debounce or throttle for a search box?

Debounce, because only the query the user finished typing matters. A wait somewhere around 250 to 400 milliseconds is the usual range. Pair it with a way to cancel or ignore stale responses, since sending fewer requests does not guarantee they come back in order.

Why does my debounced function not work in React?

Almost always because it was created inside the component body, so every render builds a fresh debounced function with a fresh empty timer and nothing is ever cancelled. Create it once with useMemo or useRef, keep the changing callback in a ref, and cancel it on unmount.

Does debouncing prevent out-of-order API responses?

No. Debouncing controls how many requests you send, and says nothing about the order responses arrive in, so a slow earlier request can still land after a fast later one and overwrite it. Cancel the previous request with AbortController, or tag each request with a sequence number and discard any response that is not the newest.

Can I test debounce and throttle without waiting in the test?

Yes, and you should. Use your test runner's fake timers to advance the clock instantly, or write the function against an injected clock you control. A test that sleeps for real is slow and will eventually fail on a loaded machine for reasons unrelated to your code.