Skip to content

Implement Debounce and Throttle

advanced22 min read

The 300ms That Cost $3 Million

A search autocomplete fires a network request on every keystroke. A user types "react hooks" at normal speed -- that's 11 characters in about 2 seconds. Without debounce, that's 11 API calls, 11 response parses, 11 DOM updates. Multiply by 50,000 concurrent users and your API is toast.

On the flip side, a resize handler recalculates a complex layout 60 times per second while the user drags a window edge. Without throttle, you're doing layout calculations every 16ms when once every 200ms would look identical.

Debounce and throttle are the two most fundamental rate-limiting patterns in frontend engineering. Every FAANG interview expects you to implement both from scratch. Here's the thing most candidates miss: the basic version takes 5 lines, but a production-grade version with leading, trailing, maxWait, cancel, and flush takes real engineering.

The Mental Model

Mental Model

Debounce is like an elevator door. Every time someone walks up (new call), the door-close timer resets. The door only closes (function fires) after nobody has arrived for a set period. If people keep arriving, the door stays open indefinitely.

Throttle is like a turnstile. It lets one person through (one function call) per time interval. Others who arrive during the cooldown period are ignored or queued. The turnstile opens again after the interval passes, regardless of how many people are waiting.

Debounce vs Throttle at a Glance

DebounceThrottle
When it firesAfter a pause in callsAt most once per interval
Resets timer on new call?Yes -- restarts the waitNo -- interval is fixed
Best forSearch input, form validation, resize endScroll handler, mousemove, game input
If called 100x in 1s (200ms delay)Fires once, 200ms after the last callFires 5 times (every 200ms)
Guarantees execution?Only after calls stopAt regular intervals during activity
Can delay indefinitely?Yes, if calls never stop (without maxWait)No -- fires every interval
Quiz
A user is typing in a search box that has both a 300ms debounce and a 300ms throttle attached (separately). They type 5 characters over 1 second at a steady pace (one every 200ms). How many times does each fire?

Building Debounce From Scratch

Level 1: The Minimal Version

This is what most candidates write in interviews. It works, but it's incomplete:

function debounce(func, wait) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, wait);
  };
}

Here's what's happening:

  1. We close over timeoutId to persist it across calls
  2. Each call clears the previous timer and starts a new one
  3. func.apply(this, args) preserves both this context and arguments
  4. The function only fires after wait milliseconds of silence
const handleSearch = debounce((query) => {
  console.log('Searching for:', query);
}, 300);

handleSearch('r');       // timer starts: 300ms
handleSearch('re');      // timer reset: 300ms
handleSearch('rea');     // timer reset: 300ms
handleSearch('reac');    // timer reset: 300ms
handleSearch('react');   // timer reset: 300ms
// 300ms of silence...
// Logs: "Searching for: react"  (fires once!)

Level 2: Leading and Trailing Options

The basic debounce fires on the trailing edge -- after the wait. But sometimes you want to fire on the leading edge -- immediately on the first call, then ignore subsequent calls until the silence period.

function debounce(func, wait, options = {}) {
  let timeoutId;
  let lastArgs;
  let lastThis;

  const leading = options.leading ?? false;
  const trailing = options.trailing ?? true;

  function invokeFunc() {
    func.apply(lastThis, lastArgs);
    lastArgs = null;
    lastThis = null;
  }

  function debounced(...args) {
    lastArgs = args;
    lastThis = this;

    const isFirstCall = timeoutId === undefined;

    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      timeoutId = undefined;
      if (trailing && lastArgs) {
        invokeFunc();
      }
    }, wait);

    if (leading && isFirstCall) {
      invokeFunc();
    }
  }

  return debounced;
}

With leading: true, the function fires immediately on the first call after a silence period. This is useful for things like a submit button -- you want the first click to register instantly, then ignore rapid double-clicks.

const handleClick = debounce(submitForm, 300, { leading: true, trailing: false });
// First click: fires immediately
// Rapid clicks within 300ms: ignored
// After 300ms of no clicks: ready for next first click
Quiz
With debounce configured as { leading: true, trailing: true } and a 500ms wait, what happens when you call the function once?

Level 3: Production-Grade With maxWait, cancel, and flush

This is the lodash-compatible version. The key addition is maxWait -- a maximum time the function can be delayed. Without it, continuous calls can delay execution indefinitely (the elevator door never closes).

function debounce(func, wait, options = {}) {
  let timeoutId;
  let lastArgs;
  let lastThis;
  let lastCallTime;
  let lastInvokeTime = 0;
  let maxTimeoutId;

  const leading = options.leading ?? false;
  const trailing = options.trailing ?? true;
  const maxWait = options.maxWait;
  const hasMaxWait = maxWait !== undefined;

  function invokeFunc(time) {
    const args = lastArgs;
    const thisArg = lastThis;
    lastArgs = null;
    lastThis = null;
    lastInvokeTime = time;
    func.apply(thisArg, args);
  }

  function startTimers(time) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(timerExpired, wait);

    if (hasMaxWait) {
      clearTimeout(maxTimeoutId);
      const timeSinceLastInvoke = time - lastInvokeTime;
      const maxRemaining = maxWait - timeSinceLastInvoke;
      maxTimeoutId = setTimeout(maxTimerExpired, Math.max(0, maxRemaining));
    }
  }

  function timerExpired() {
    const time = Date.now();
    timeoutId = undefined;
    if (trailing && lastArgs) {
      invokeFunc(time);
    }
    clearTimeout(maxTimeoutId);
    maxTimeoutId = undefined;
  }

  function maxTimerExpired() {
    const time = Date.now();
    maxTimeoutId = undefined;
    if (lastArgs) {
      invokeFunc(time);
    }
    clearTimeout(timeoutId);
    timeoutId = undefined;
    startTimers(time);
  }

  function debounced(...args) {
    const time = Date.now();
    lastArgs = args;
    lastThis = this;
    lastCallTime = time;

    const isFirstCall = timeoutId === undefined && maxTimeoutId === undefined;

    startTimers(time);

    if (leading && isFirstCall) {
      invokeFunc(time);
    }
  }

  debounced.cancel = function () {
    clearTimeout(timeoutId);
    clearTimeout(maxTimeoutId);
    timeoutId = undefined;
    maxTimeoutId = undefined;
    lastArgs = null;
    lastThis = null;
    lastInvokeTime = 0;
  };

  debounced.flush = function () {
    if (timeoutId === undefined && maxTimeoutId === undefined) return;
    const time = Date.now();
    clearTimeout(timeoutId);
    clearTimeout(maxTimeoutId);
    timeoutId = undefined;
    maxTimeoutId = undefined;
    if (lastArgs) {
      invokeFunc(time);
    }
  };

  debounced.pending = function () {
    return timeoutId !== undefined || maxTimeoutId !== undefined;
  };

  return debounced;
}

Here's why each piece matters:

  • maxWait: Guarantees the function fires within this time window. Prevents indefinite delay. Think of it as "the elevator door closes after 10 seconds no matter what."
  • cancel(): Kills pending invocations. Essential for cleanup when a component unmounts or a user navigates away.
  • flush(): Forces immediate execution of any pending invocation. Useful for "save on blur" -- if the user clicks away, flush the debounced save.
  • pending(): Returns whether there's a pending invocation. Useful for UI indicators.
const save = debounce(saveToServer, 1000, { maxWait: 5000 });

// User types continuously for 8 seconds:
// Without maxWait: save fires once, 1 second after they stop (could be never)
// With maxWait 5000: save fires at 5s, then again 1s after they stop
Quiz
You have a debounced save function with wait: 1000 and maxWait: 3000. The user types continuously without stopping. When does the save fire?

Building Throttle From Scratch

Level 1: The Minimal Version

function throttle(func, wait) {
  let lastCallTime = 0;

  return function (...args) {
    const now = Date.now();

    if (now - lastCallTime >= wait) {
      lastCallTime = now;
      func.apply(this, args);
    }
  };
}

This fires on the leading edge only. The first call goes through immediately, then subsequent calls within the wait period are dropped. Simple, but it has a problem: the last call during a burst is lost.

Level 2: Leading and Trailing With Timer

The production version ensures the function fires both at the start of the interval (leading) and at the end (trailing), so you don't lose the final call:

function throttle(func, wait, options = {}) {
  let timeoutId;
  let lastArgs;
  let lastThis;
  let lastCallTime = 0;

  const leading = options.leading ?? true;
  const trailing = options.trailing ?? true;

  function invokeFunc() {
    func.apply(lastThis, lastArgs);
    lastArgs = null;
    lastThis = null;
  }

  function throttled(...args) {
    const now = Date.now();
    lastArgs = args;
    lastThis = this;

    const elapsed = now - lastCallTime;

    if (elapsed >= wait) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      lastCallTime = now;

      if (leading) {
        invokeFunc();
      }
    }

    if (!timeoutId && trailing) {
      timeoutId = setTimeout(() => {
        lastCallTime = leading ? Date.now() : 0;
        timeoutId = undefined;
        invokeFunc();
      }, wait - elapsed);
    }
  }

  throttled.cancel = function () {
    clearTimeout(timeoutId);
    timeoutId = undefined;
    lastArgs = null;
    lastThis = null;
    lastCallTime = 0;
  };

  return throttled;
}

Why both leading and trailing matter:

  • Leading only ({ trailing: false }): Good for click handlers. Fires on first click, ignores rapid subsequent clicks. But loses the last state update.
  • Trailing only ({ leading: false }): Good when you need the most recent value. Fires at the end of each interval with the latest args.
  • Both (default): Best for scroll/resize. Fires immediately for responsiveness, then again at the end to capture the final position.
const handleScroll = throttle((e) => {
  updateProgressBar(window.scrollY);
}, 100);

window.addEventListener('scroll', handleScroll, { passive: true });
Common Trap

Never set both leading: false and trailing: false. The function would never fire -- every call would be silently dropped. Lodash explicitly warns about this in their docs, and your implementation should either throw or treat it as trailing: true.

The Lodash Relationship: Throttle is Debounce With maxWait

Here's an insight that simplifies everything: throttle is just debounce where maxWait equals wait. That's literally how lodash implements it:

function throttle(func, wait, options) {
  return debounce(func, wait, {
    leading: options?.leading ?? true,
    trailing: options?.trailing ?? true,
    maxWait: wait,
  });
}

Think about why this works. Debounce with maxWait equal to wait means "delay up to wait ms, but never longer than wait ms." That's throttle -- at most one execution per wait period. If you implement debounce correctly with maxWait, you get throttle for free.

Quiz
Why is throttle(fn, 200) equivalent to debounce(fn, 200, { maxWait: 200 })?

requestAnimationFrame-Based Throttle

For visual updates like scroll position, layout recalculation, or animation-driven UI, timestamp-based throttle is wasteful. You don't need to fire every 200ms -- you need to fire once per frame, synchronized with the browser's render cycle:

function throttleRAF(func) {
  let frameId;
  let lastArgs;
  let lastThis;

  function throttled(...args) {
    lastArgs = args;
    lastThis = this;

    if (frameId === undefined) {
      frameId = requestAnimationFrame(() => {
        frameId = undefined;
        func.apply(lastThis, lastArgs);
        lastArgs = null;
        lastThis = null;
      });
    }
  }

  throttled.cancel = function () {
    if (frameId !== undefined) {
      cancelAnimationFrame(frameId);
      frameId = undefined;
      lastArgs = null;
      lastThis = null;
    }
  };

  return throttled;
}

Why rAF over setTimeout:

  • Automatically syncs with the display refresh rate (usually 60fps = ~16.6ms)
  • Pauses when the tab is backgrounded (saves battery, prevents hidden computation)
  • The callback runs before paint, so your DOM updates are fresh for that frame
  • No jank from timer drift -- setTimeout(fn, 16) is not reliably 16ms
const handleScroll = throttleRAF(() => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  progressBar.style.transform = `scaleX(${progress})`;
});

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

Use rAF throttle for anything that updates the DOM during scroll, resize, or drag. Use time-based throttle for things like API calls, analytics events, or WebSocket messages where frame timing doesn't matter.

Real-World Use Cases

Search Input (Debounce)

const searchInput = document.getElementById('search');

const fetchResults = debounce(async (query) => {
  if (!query.trim()) return;
  const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  renderResults(await results.json());
}, 300);

searchInput.addEventListener('input', (e) => {
  fetchResults(e.target.value);
});

300ms is the sweet spot for search. Shorter feels responsive but wastes API calls. Longer and the user notices the delay.

Form Validation (Debounce)

const validateEmail = debounce(async (email) => {
  const response = await fetch(`/api/validate-email?email=${encodeURIComponent(email)}`);
  const { available } = await response.json();
  showValidationState(available ? 'available' : 'taken');
}, 500);

Auto-Save (Debounce With maxWait)

const autoSave = debounce(
  (content) => saveToServer(content),
  2000,
  { maxWait: 10000 }
);
// Saves 2s after user stops editing, but at least every 10s during continuous editing

Scroll Progress (rAF Throttle)

const updateProgress = throttleRAF(() => {
  const scrolled = window.scrollY;
  const total = document.body.scrollHeight - window.innerHeight;
  progressElement.style.width = `${(scrolled / total) * 100}%`;
});

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

Resize Handler (Throttle)

const recalcLayout = throttle(() => {
  const width = window.innerWidth;
  if (width < 768) switchToMobileLayout();
  else switchToDesktopLayout();
}, 200);

window.addEventListener('resize', recalcLayout);

Infinite Scroll (Throttle)

const checkScrollPosition = throttle(() => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  if (scrollHeight - scrollTop - clientHeight < 200) {
    loadMoreItems();
  }
}, 250);

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

Cleanup Matters

This is the part candidates forget. In React or any component-based framework, you must cancel pending debounced/throttled calls when the component unmounts:

// In React:
useEffect(() => {
  const handleResize = debounce(recalculate, 200);
  window.addEventListener('resize', handleResize);

  return () => {
    handleResize.cancel();
    window.removeEventListener('resize', handleResize);
  };
}, []);

Without .cancel(), the debounced function fires after the component is gone, calling setState on an unmounted component (or worse, updating DOM elements that no longer exist).

Quiz
Why is it critical to call debounced.cancel() in a React cleanup function rather than just removing the event listener?

Common Mistakes

What developers doWhat they should do
Creating a new debounced function on every render in React
If you create debounce(fn, 300) inside a render, each render creates a fresh closure with its own timeoutId. The previous debounce timer is lost and the new one starts from zero. The function fires on every render -- debounce is completely broken.
Create the debounced function once with useRef or useMemo, and clean it up with useEffect
Using throttle for search input autocomplete
Throttle at 300ms on search input sends 'r', 'rea', 'react' as separate API calls. Debounce at 300ms sends only 'react' once the user stops typing. Fewer requests, better results.
Use debounce. Throttle fires during typing at intervals, sending partial queries. Debounce waits until the user pauses, sending only complete queries.
Forgetting to preserve this context and arguments
If the debounced function is a method on an object, calling func() loses the this binding. The original arguments also need to be captured in the closure since setTimeout runs later with no arguments.
Use func.apply(this, args) inside the setTimeout callback, not func()
Using debounce with wait: 0 thinking it does nothing
setTimeout(fn, 0) doesn't mean 'run immediately.' It means 'run after the current call stack clears and pending microtasks drain.' This is actually useful for batching multiple synchronous state updates into a single execution.
debounce(fn, 0) still defers execution to the next event loop tick via setTimeout(fn, 0). It batches synchronous calls and fires once.
Not cleaning up debounce/throttle on component unmount
A pending setTimeout doesn't care that your component unmounted. It fires and calls your function with references to stale state or removed DOM elements, causing errors or memory leaks.
Always call cancel() in your cleanup function to prevent stale callbacks from firing after unmount

Interview Tips

When implementing debounce or throttle in an interview, build it up in layers. Don't try to write the full version from the start:

  1. Start with the basic version (5 lines). Explain clearTimeout + setTimeout pattern.
  2. Add this and args handling. Explain why func.apply(this, args) matters.
  3. Add leading/trailing options if asked. Most interviewers are happy with the basic version plus a verbal explanation of how you'd add options.
  4. Mention cancel and flush. Even if you don't implement them, showing you know they exist demonstrates production awareness.
  5. Mention the lodash relationship. Saying "throttle is debounce with maxWait equal to wait" shows deep understanding.
Edge case: what if wait is 0?

debounce(fn, 0) is not a no-op. setTimeout(fn, 0) defers execution to the next macrotask. This is actually useful: it batches multiple synchronous calls into a single execution at the end of the current call stack.

const batchUpdate = debounce(renderUI, 0);

batchUpdate(); // queued
batchUpdate(); // previous cleared, re-queued
batchUpdate(); // previous cleared, re-queued
// renderUI fires once, after all synchronous code finishes

This is conceptually similar to how React batches state updates.

Key Rules

Key Rules
  1. 1Debounce delays execution until calls stop. Throttle limits execution to once per interval. Pick the right one for your use case.
  2. 2Always preserve this context and arguments with func.apply(this, args). Losing context is the most common implementation bug.
  3. 3Throttle is just debounce with maxWait equal to wait. Master debounce with maxWait and you get throttle for free.
  4. 4Use requestAnimationFrame-based throttle for DOM updates (scroll, resize, drag). Use time-based throttle for API calls and analytics.
  5. 5Always implement cancel() for cleanup. In React, call it in useEffect's return function. Stale timers after unmount cause real bugs.
  6. 6Both leading and trailing options exist. Leading fires on the first call, trailing fires after the wait. The default matters -- debounce defaults to trailing, throttle defaults to both.