JavaScript Debounce vs Throttle — When to Use Which

tutorialMay 25, 2026· 6 min read

I used to slap debounce on everything and call it a day. Then I shipped a throttle on a scroll handler that should have been debounced, and watched the UI stutter for 30 seconds before recovering. That's when I actually learned the difference — not from MDN, but from breaking production.

Here's what I wish someone had told me early: debounce and throttle solve different problems, and picking the wrong one makes your app worse, not better.

The Core Difference in One Sentence

Debounce waits for a pause before firing. Throttle fires at a steady rate, no matter what.

That's it. Everything else follows from this distinction.

Debounce — Wait for the User to Stop

Debounce delays the function call until the user stops triggering it for a specified time. If they keep triggering it, the timer resets.

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Every new call cancels the previous timer. The function only fires once — after the last call and the delay period.

When debounce is the right choice:

Search input — you don't want to query the API on every keystroke:

const searchInput = document.querySelector('#search');
const handleSearch = debounce(async (query) => {
  const results = await fetch(`/api/search?q=${query}`);
  renderResults(await results.json());
}, 300);

searchInput.addEventListener('input', (e) => handleSearch(e.target.value));

User types "javascript" (10 characters). Without debounce: 10 API calls. With debounce: 1 call, 300ms after they stop typing.

Form validation — validate when the user finishes editing a field:

const validateEmail = debounce((email) => {
  if (!email.includes('@')) {
    showError('Enter a valid email address');
  }
}, 500);

emailInput.addEventListener('input', (e) => validateEmail(e.target.value));

Auto-save — save when the user pauses writing:

const autoSave = debounce(async (content) => {
  await fetch('/api/draft', {
    method: 'PUT',
    body: JSON.stringify({ content }),
  });
}, 2000);

editor.addEventListener('input', (e) => autoSave(e.target.value));

The pattern: wait for the user to finish doing something, then act once.

Throttle — Fire at a Steady Rate

Throttle fires the function at most once every X milliseconds. It guarantees a maximum rate, regardless of how often the event fires.

function throttle(fn, limit) {
  let inThrottle = false;
  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

The first call fires immediately. Subsequent calls within the limit window are ignored. Then the window resets.

When throttle is the right choice:

Scroll handler — you need to track scroll position at a reasonable rate:

const onScroll = throttle(() => {
  const scrollPercent =
    window.scrollY / (document.body.scrollHeight - window.innerHeight);
  updateProgressBar(scrollPercent);
}, 100);

window.addEventListener('scroll', onScroll, { passive: true });

Scroll events fire 60+ times per second. Without throttle, you're running layout calculations on every frame. With throttle at 100ms, you get ~10 updates per second — smooth enough for a progress bar, light enough for performance.

Mouse move — tracking cursor position for a drawing app or tooltip:

const trackMouse = throttle((x, y) => {
  sendToAnalytics({ x, y, timestamp: Date.now() });
}, 200);

canvas.addEventListener('mousemove', (e) => trackMouse(e.clientX, e.clientY));

Resize handler — recalculating layout when the window changes size:

const handleResize = throttle(() => {
  chart.resize(window.innerWidth, window.innerHeight);
}, 150);

window.addEventListener('resize', handleResize);

Rate-limiting API calls — a "load more" button that users might spam:

const loadMore = throttle(async () => {
  const data = await fetch(nextPageUrl);
  appendToFeed(await data.json());
}, 1000);

loadMoreBtn.addEventListener('click', loadMore);

The pattern: keep firing at a controlled rate, never exceed the limit.

The Key Distinction: Leading vs Trailing

This is where most developers get confused.

ScenarioDebounceThrottle
User types 10 chars rapidlyFires once after they stopFires multiple times at steady intervals
User scrolls continuouslyFires once after they stop scrollingFires regularly while scrolling
User clicks button 20x in 2sFires once after 2s silenceFires 2–3 times (depending on limit)
Continuous stream of eventsWaits for silence, then firesPaces the output at a fixed rate

Debounce optimizes for "give me the final value." Throttle optimizes for "give me updates at a reasonable rate."

Leading Edge Variants

Sometimes you want the first call to fire immediately, not after a delay.

Debounce with leading edge — useful for button clicks where the first click should always work:

function debounceLeading(fn, delay, { leading = false } = {}) {
  let timer = null;
  return function (...args) {
    if (timer === null && leading) {
      fn.apply(this, args);
    }
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, delay);
  };
}

Throttle with trailing call — fire the last queued call after the window closes, so you don't miss the final value:

function throttleTrailing(fn, limit) {
  let inThrottle = false;
  let lastArgs = null;

  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
        if (lastArgs) {
          fn.apply(this, lastArgs);
          lastArgs = null;
        }
      }, limit);
    } else {
      lastArgs = args;
    }
  };
}

Use the trailing variant for resize handlers where you need the final dimensions, not just intermediate ones.

The Decision Framework

When you're unsure which to use, ask yourself:

"Do I need the final result, or do I need periodic updates?"

  • Final result → debounce (search, validation, auto-save)
  • Periodic updates → throttle (scroll, resize, mousemove, analytics)

"Does the intermediate state matter?"

  • No (only the last value matters) → debounce
  • Yes (I need to show progress/updates along the way) → throttle

"What happens if I miss the last event?"

  • Bad (wrong search results, missed auto-save) → debounce
  • Fine (scroll position will update soon anyway) → throttle

Common Mistakes I've Made

1. Debouncing scroll handlers

// ❌ Bad — progress bar doesn't update until user stops scrolling
window.addEventListener('scroll', debounce(updateProgress, 100));

// ✅ Good — smooth updates at 10fps
window.addEventListener('scroll', throttle(updateProgress, 100));

With debounce, the progress bar freezes while the user scrolls and jumps to the end when they stop. Feels broken.

2. Throttling search inputs

// ❌ Bad — fires while user is still typing, wastes API calls
searchInput.addEventListener('input', throttle(handleSearch, 300));

// ✅ Good — waits for typing to pause
searchInput.addEventListener('input', debounce(handleSearch, 300));

With throttle, you get partial queries like "jav", "javascr", "javascript" — 3 API calls instead of 1.

3. Using arrow functions with debounce/throttle

// ❌ Bad — creates a NEW debounced function every render
button.addEventListener('click', (e) => debounce(handleClick, 1000)(e));

// ✅ Good — store the debounced function
const debouncedClick = debounce(handleClick, 1000);
button.addEventListener('click', debouncedClick);

Every time the event fires with the wrong pattern, you create a new debounced function with its own timer. The debounce does nothing — every call gets its own isolated timer.

4. Forgetting to clean up

// ❌ Bad — debounce timer fires after component unmounts
useEffect(() => {
  const debouncedSave = debounce(saveData, 1000);
  return () => {
    // timer still fires!
  };
}, []);

// ✅ Good — cancel on cleanup
useEffect(() => {
  const debouncedSave = debounce(saveData, 1000);
  return () => {
    debouncedSave.cancel?.(); // if using lodash
    // or: clearTimeout(debouncedSave.timer);
  };
}, []);

React: Custom Hooks for Both

If you're using React, here are production-ready hooks:

function useDebounce(callback, delay) {
  const timerRef = useRef(null);

  const debouncedFn = useCallback(
    (...args) => {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => callback(...args), delay);
    },
    [callback, delay]
  );

  useEffect(() => {
    return () => clearTimeout(timerRef.current);
  }, []);

  return debouncedFn;
}

function useThrottle(callback, limit) {
  const inThrottle = useRef(false);
  const lastArgs = useRef(null);

  const throttledFn = useCallback(
    (...args) => {
      if (!inThrottle.current) {
        callback(...args);
        inThrottle.current = true;
        setTimeout(() => {
          inThrottle.current = false;
          if (lastArgs.current) {
            callback(...lastArgs.current);
            lastArgs.current = null;
          }
        }, limit);
      } else {
        lastArgs.current = args;
      }
    },
    [callback, limit]
  );

  return throttledFn;
}

Usage:

function SearchComponent() {
  const [query, setQuery] = useState('');

  const debouncedSearch = useDebounce(async (q) => {
    const res = await fetch(`/api/search?q=${q}`);
    setResults(await res.json());
  }, 300);

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

Quick Reference

Use CaseFunctionDelayWhy
Search inputdebounce250–400msWait for typing to pause
Form validationdebounce400–600msValidate after editing
Auto-savedebounce1000–2000msSave after inactivity
Scroll handlerthrottle100–150msSmooth updates, no jank
Resize handlerthrottle100–200msLayout recalc at reasonable rate
Mouse move trackingthrottle50–200msDepends on precision needed
Button spam preventionthrottle500–1000msFirst click works, block spam
Window resize + final dimensionsthrottleTrailing150msPeriodic + catch final value

Takeaways

  • Debounce = wait for silence. Use when you only care about the final value.
  • Throttle = cap the rate. Use when you need periodic updates at a controlled speed.
  • Search, validation, auto-save → debounce. Scroll, resize, mousemove → throttle.
  • Store the debounced/throttled function — don't recreate it on every call.
  • Clean up timers in React effects and component unmounts.
  • When in doubt, ask: "Do I need the intermediate values?" No = debounce. Yes = throttle.