Async JavaScript & Timing
Async JavaScript & Timing

Debounce & throttle from scratch

Full · ~40 min
After this unit you can
  • Write a trailing-edge debounce that forwards this and the latest arguments, with working cancel() and flush()
  • Write a leading-and-trailing throttle whose trailing run uses the latest call, not the call that started the timer
  • Predict which calls run, and when, under trailing, leading, leading-plus-trailing and maxWait settings
  • Explain why continuous input needs maxWait, why throttle is a debounce with maxWait equal to wait, and why a fresh wrapper per call debounces nothing

Read

Simple
1 / 2

A debounce is a timer you keep resetting

Flag

A debounced function waits for the calls to stop. Each call cancels the timer the previous call started and starts a new one, so fn runs once, wait ms after the last call, with that call's arguments.

debounce is a function that takes your function and returns a new one. The timer handle lives in the closure, shared by every call to the returned function:

function debounce(fn, wait) {
  let timer; // shared by every call to the function we return

  return (...args) => {
    clearTimeout(timer);                        // cancel the previous call's timer
    timer = setTimeout(() => fn(...args), wait); // and start the clock again
  };
}

const search = debounce((q) => console.log("search", q), 300);
search("r");
search("re");
search("rea"); // ...then 300 ms of silence
// search rea

Three calls, one run, with the last arguments. "r" and "re" were scheduled, then cancelled before their 300 ms were up.

That's the trailing edge: the run happens at the end of the burst. It's what you want when only the final value matters (search-as-you-type, autosave, "resize finished"). Its weakness is the flip side of its strength: if the calls never stop, fn never runs.

Two things this ten-line version gets wrong, which the medium tier fixes:

  • this is lost. An arrow function has no this of its own, and fn(...args) calls fn as a plain function. Attach the debounced function to an object as a method and fn won't see that object.
  • There's no way to cancel or flush. A component being torn down needs to drop the pending call (or run it now), and nothing outside the closure can reach timer.

Medium
1 / 2

A complete debounce, line by line

Flag

The version you'd actually ship forwards this, always uses the latest call's arguments, and exposes cancel() and flush():

export function debounce<F extends (...args: any[]) => void>(fn: F, wait: number) {
  let timer: ReturnType<typeof setTimeout> | undefined; // set ⇔ a call is pending
  let lastArgs: Parameters<F> | undefined;
  let lastThis: ThisParameterType<F> | undefined;

  function invoke() {
    const args = lastArgs!;
    const thisArg = lastThis;
    timer = lastArgs = lastThis = undefined; // nothing is pending any more
    fn.apply(thisArg, args);
  }

  function debounced(this: ThisParameterType<F>, ...args: Parameters<F>) {
    lastArgs = args;
    lastThis = this;
    clearTimeout(timer);
    timer = setTimeout(invoke, wait);
  }

  debounced.cancel = () => {
    clearTimeout(timer);
    timer = lastArgs = lastThis = undefined;
  };

  debounced.flush = () => {
    if (timer === undefined) return; // nothing pending: don't run anything
    clearTimeout(timer);
    invoke();
  };

  return debounced;
}

What each piece is for:

  • function debounced, not an arrow. A function gets this from how it's called, so editor.save = debounce(saveDraft, 500); editor.save() gives saveDraft the editor. Saving this alongside the arguments and calling fn.apply(thisArg, args) passes both through.
  • lastArgs is overwritten on every call. The timer callback reads them when it fires, so the run always uses the final call's arguments. Closing over the args of the call that created a timer is the classic way to get stale arguments (the throttle reading shows it).
  • timer doubles as the "pending" flag. It's set while a run is scheduled and cleared in invoke. That one invariant is what makes flush correct. Forget to clear it when the timer fires, and a later flush() runs fn again with old arguments.
  • cancel must call clearTimeout. Setting timer = undefined alone forgets the handle, but the scheduled run still fires. Clearing the handle is what stops it.
  • flush cancels the timer before running. Run fn without cancelling, and the timer fires again wait ms later: two runs for one burst.

When do you need them? cancel() on teardown: the screen is going away, so drop the pending write. flush() when the user does something explicit: they pressed Save or closed the tab, so run the pending call now rather than in 500 ms.

Complex
1 / 2

Leading edges, maxWait, and why throttle is a debounce

Flag

Debounce and throttle are two settings of one machine. Lodash (4.x) exposes the knobs on debounce(fn, wait, { leading, trailing, maxWait }), and its throttle is literally debounce(fn, wait, { leading: true, trailing: true, maxWait: wait }).

The edges. For a burst of calls a b c with less than wait between them:

OptionsRuns
trailing only (debounce's default)c, wait ms after the last call
leading onlya, immediately; nothing else until wait ms of silence
leading + trailinga immediately, then c after the silence

A single call with leading + trailing runs once, not twice. Lodash's docs: the trailing run happens "only if the debounced function is invoked more than once during the wait timeout." Leading-only debounce suits "act on the first click, ignore the double-click".

maxWait: a deadline for the burst. A trailing debounce has one failure mode: input that never pauses. Someone typing steadily, or a sensor reporting every 50 ms against a 100 ms debounce, gets no runs at all until it stops. maxWait adds a second timer that starts with the burst and is not reset by later calls:

function debounce(fn, wait, { maxWait } = {}) {
  let timer, maxTimer, lastArgs, lastThis;

  function invoke() {
    clearTimeout(timer);
    clearTimeout(maxTimer);
    timer = maxTimer = undefined;
    const args = lastArgs, thisArg = lastThis;
    lastArgs = lastThis = undefined;
    fn.apply(thisArg, args);
  }

  return function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    clearTimeout(timer);
    timer = setTimeout(invoke, wait);         // reset on every call
    if (maxWait !== undefined && maxTimer === undefined) {
      maxTimer = setTimeout(invoke, maxWait); // NOT reset: the burst's deadline
    }
  };
}

Calling it every 10 ms for 300 ms with wait: 50, maxWait: 120 runs at about 120 ms, 240 ms, and once more after the input stops. Without maxWait, it runs once, at about 350 ms. Lodash produces the same pattern.

Now set maxWait equal to wait and add a leading edge. A run is due at least once per wait while calls keep coming, with one at the start and one at the end. That's a throttle, which is why lodash builds it this way.

Quiz

editor.save = debounce(saveDraft, 500), and saveDraft reads this.id. The debounce wraps with (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); }. What this does saveDraft see after editor.save()?

Flag

A debounce's cancel() is written as () => { timer = undefined; }. What happens to a pending run?

Flag

A throttle schedules its trailing run with setTimeout(() => fn.apply(this, args), remaining) inside the first call that finds the window closed. A burst reports scroll positions 0, 40, 80, 120. The trailing run reports 40. Why?

Flag

A sensor calls a 100 ms trailing debounce every 50 ms, without pause, for an hour. How often does fn run during that hour?

Flag

Lodash (4.x) implements throttle(fn, wait) as which debounce call?

Flag

A debounce's invoke runs fn.apply(lastThis, lastArgs) but never resets timer. Its flush is if (timer !== undefined) { clearTimeout(timer); invoke(); }. The user types, the save fires 500 ms later, then they click Save, which calls flush(). What happens?

Flag

In a leading-and-trailing throttle, why must the trailing run also record lastRun = Date.now()?

Flag

Fill in the blank

Save the caller's receiver along with the arguments, and pass both on when the timer fires.

Flag
function debounced(...args) {
  lastArgs = args;
  lastThis = ;
  clearTimeout(timer);
  timer = setTimeout(invoke, wait);
}

function invoke() {
  const args = lastArgs, thisArg = lastThis;
  timer = lastArgs = lastThis = undefined;
  fn.(thisArg, args);
}

Make cancel() actually stop the pending run.

Flag
debounced.cancel = () => { (timer); timer = lastArgs = lastThis = undefined; };

The throttle's trailing timer must deliver the latest call, not the call that created the timer. Which variable does it read?

Flag
// on every call while the window is closed: pendingArgs = args;
timer = setTimeout(() => {
  const latest = ;
  timer = pendingArgs = undefined;
  run(latest);
}, remaining);

Build a throttle out of a lodash-style debounce.

Flag
const throttle = (fn, wait) => debounce(fn, wait, { leading: true, trailing: true, : wait });

Predict the output

import debounce from "lodash/debounce"; // lodash 4.x

const save = debounce((v) => console.log("save", v), 100, { leading: true, trailing: true });

save("a");
setTimeout(() => {
  save("b");
  save("c");
}, 300);
Flag
const searches = [];
const search = (q) => searches.push(q);

// a debounce like the one in the readings, 30 ms
const onInput = (value) => debounce(search, 30)(value);

onInput("r");
onInput("re");
onInput("rea");
setTimeout(() => console.log(searches), 100);
Flag
import debounce from "lodash/debounce"; // lodash 4.x

let runs = 0;
const onReading = debounce(() => { runs += 1; }, 50, { maxWait: 120 });

const start = Date.now();
const id = setInterval(() => {
  onReading();
  if (Date.now() - start > 300) clearInterval(id);
}, 10);

setTimeout(() => console.log("runs:", runs), 600);
Flag

Challenge

Finish a hand-rolled debounce and throttle

Flag

rate-limit.ts holds the two utilities a search box, an autosave and a scroll handler share:

  • debounce(fn, wait) runs fn once, wait ms after the last call, with that call's this and arguments. The returned function also has cancel() (drop the pending run) and flush() (run it now).
  • throttle(fn, wait) runs the first call immediately, then at most once per wait. Calls that arrive while the window is closed collapse into one trailing run, with the latest call's this and arguments, so the last value always gets through. It has cancel() too.

Both pass a quick manual test, which is how they shipped. The six bugs only show up in how they're actually used: a debounced method on an object, a component that cancels on teardown, a Save button that flushes, a scroll handler whose final position matters. The comment above debounce lists them in the order worth fixing.

WAIT_MS in config.ts is the delay the tests use.

Your task

Edit rate-limit.ts so the tests pass:

  • debounce runs fn once, wait ms after the last call, with the last call's arguments
  • debounce passes fn the this it was called with
  • debounced.cancel() drops a pending run for good: it never fires, and a later flush() has nothing to run
  • debounced.flush() runs a pending call immediately and exactly once, and does nothing when nothing is pending
  • throttle runs the first call immediately and never runs twice within wait
  • throttle's trailing run uses the latest call's this and arguments, and a single call runs only once
  • throttled.cancel() drops the pending trailing run

Loading the in-browser editor…

Further reading