AniUI Academy

Debouncing, Throttling, and the Right Complexity Framing

Debounce and throttle look like timer tricks, but the sliding-window model from earlier in this course explains their guarantees — and a naive implementation's real bugs.

10 min read

Debounce and throttle are usually taught as timer tricks — "just wrap it in setTimeout" — without ever connecting them to the algorithmic idea they actually are. This capstone makes that connection explicit: both are the sliding window pattern from earlier in this course, running on a stream of real user events instead of an array.

Debounce: a variable-size window that only resolves on quiet

function debounce(fn, delayMs) {
  let timeoutId;
 
  return function debounced(...args) {
    clearTimeout(timeoutId); // cancel whatever was scheduled — the window keeps extending
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}
 
const search = debounce((query) => {
  console.log("Searching for:", query);
}, 300);

Every call to debounced(...) cancels the previously scheduled call and schedules a brand-new one. Recall the sliding-window lesson's variable-size window: it keeps extending on every new event, and only resolves once the extending condition (in that case, "no duplicate seen") is no longer met. Debounce is the exact same shape, running on a timeline instead of an array: the "window" is "time since the last call," it keeps extending on every new keystroke, and it only resolves — actually calling fn — once 300ms pass with no new calls arriving to extend it again. That's why debounce is right for "wait until the user stops typing" — it's a window that structurally cannot close while events keep arriving.

Throttle: a fixed-size window that always resolves on schedule

function throttle(fn, intervalMs) {
  let lastCallTime = 0;
 
  return function throttled(...args) {
    const now = Date.now();
    if (now - lastCallTime >= intervalMs) {
      lastCallTime = now;
      fn(...args);
    }
  };
}
 
const updateScrollProgress = throttle(() => {
  console.log("Scroll progress updated");
}, 100);

Throttle maps onto the sliding window lesson's fixed-size window instead: a fixed 100ms bucket that always resolves — fires, if eligible — on its own schedule, regardless of how many events land inside it. This is why throttle, not debounce, is correct for a continuously-updating scroll indicator: debounce would wait for scrolling to fully stop before firing even once, producing zero visual feedback during active scrolling; throttle guarantees periodic updates throughout the entire continuous event stream, capped at a rate you control.

  1. Step 1

    Debounce = variable-size window

    Keeps extending on every event; resolves only once events stop arriving for the full quiet period.

  2. Step 2

    Right choice for: search-as-you-type

    You want ONE call, after the user is done, not one per keystroke.

  3. Step 3

    Throttle = fixed-size window

    A fixed time bucket that fires on schedule regardless of how many events arrive inside it.

  4. Step 4

    Right choice for: scroll/resize handlers

    You want periodic updates DURING continuous activity, not silence until it stops.

The same sliding-window shapes from earlier in this course, running on a stream of events instead of an array.

The real complexity win, made concrete

Without either technique, a handler wired directly to input or scroll events runs once per event — and a fast typist or a smooth scroll gesture can fire dozens of events per second. If that handler does real work (an API call, a DOM measurement, a re-render), the total cost across a session is (events fired) × (cost per handler call) — precisely the "cheap-looking operation, called repeatedly" trap this entire course opened with. Debounce reduces (events fired) down to roughly one call per pause in activity; throttle caps it to a fixed rate regardless of the event frequency. Neither changes the complexity of the handler itself — they change how many times it's allowed to run, which is often the entire actual problem.

The real gotcha: closures retaining more than they should

// Created fresh, per row, in a large virtualized list — a real anti-pattern
function renderRow(rowData) {
  const onScroll = throttle(() => {
    updateRowVisibility(rowData); // captures rowData in THIS row's closure
  }, 100);
  // ... attach onScroll ...
}

Each call to renderRow creates a brand-new throttle wrapper, with its own closure capturing rowData. For a virtualized list with thousands of rows, that's thousands of separate closures, each retaining its own captured reference — a real, measurable O(n) memory cost, exactly the space- complexity concern from earlier in this course, here caused by creating per-item wrappers instead of one shared, reusable one that takes the row as an argument at call time.

Try it yourself
Loading playground...

What to remember

  • Debounce is a variable-size sliding window that keeps extending while events arrive and resolves only once they stop — right for "wait until the user is done" (search-as-you-type).
  • Throttle is a fixed-size sliding window that resolves on a fixed schedule regardless of event frequency — right for "keep updating periodically during continuous activity" (scroll, resize, drag).
  • Both exist to reduce how many times an expensive handler actually runs, turning a per-event cost into a per-pause or per-interval cost — the same "avoid paying a real cost on every occurrence of a cheap-looking trigger" theme from this entire course.
  • Creating a fresh debounce/throttle wrapper per item in a large list (rather than one shared wrapper) multiplies closure memory by the item count — a real, avoidable space cost.

Check yourself

4 questions · pass 3/4 to unlock Virtualizing a List as an Algorithms Problem

up to 50
  1. 1.A naive debounce implementation captures the event argument in a closure at the moment setTimeout is scheduled, rather than reading it fresh when the timer finally fires. What real bug does this cause on a rapidly-typed search box?

  2. 2.Debounce delays execution until events STOP arriving for a specified quiet period; throttle guarantees execution happens at most once per specified interval, REGARDLESS of whether events keep arriving. For a scroll-position handler that must update a 'scroll progress' indicator smoothly and continuously while scrolling, which is the correct choice, and why?

  3. 3.This course's sliding-window lesson described a variable-size window whose right edge extends on every new event and whose left edge only resolves once the extending condition stops. Which of debounce or throttle does this most directly correspond to?

  4. 4.What is the space complexity concern with a naive debounce/throttle wrapper that captures the full event object (rather than just the values actually needed) in its closure, on a page with thousands of debounced handlers created dynamically (e.g., one per row in a large virtualized list)?

4 left to answer