shtabnoy.com / challenges / debounce
✦ JS fundamentals

Building debounce
from scratch

Type fast and watch what happens. The raw handler fires on every keystroke. The debounced version waits until you stop typing. Same input, dramatically different behavior — and it's only 20 lines of code.

closuressetTimeoutthis bindinggenericscleanup
// try it

Type something and watch the timeline

Every keystroke fires the raw handler immediately. The debounced version only fires once you pause for 300ms. Try typing fast, then slow, and see how the calls differ.

Raw calls
0
Debounced calls
0
Calls saved
0
Event timeline →
Start typing above to see the timeline
// the code

20 lines that save thousands of API calls

A closure, a timer, and clearTimeout. That's the entire trick. The generic typing and cancel method are what make it production-grade.

debounce.ts
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
  let timerId: ReturnType<typeof setTimeout> | null = null;

  function cancel() {
    if (timerId) {
      clearTimeout(timerId);
      timerId = null;
    }
  }

  const debouncedFn = function (this: any, ...args: Parameters<T>) {
    cancel();
    timerId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };

  debouncedFn.cancel = function () {
    cancel();
  };

  return debouncedFn;
}
💡 Key insight

The timerId variable is trapped in a closure — it persists across calls but is invisible from outside. Each invocation of the debounced function reads and mutates the same timer. This is closures doing real work, not just an interview trivia question.

⚠ React gotcha

Don't create the debounced function inside a component render — it gets recreated every render, losing the timer state. Use useRef or useMemo to keep a stable reference. And always call cancel() in your cleanup function.

// takeaways

What this exercise teaches

Closures hold state
The timerId variable lives in a closure, invisible to the outside world. Each call to the debounced function reads and writes the same timerId — that's what makes cancellation work.
🔄
clearTimeout is the key
Every new call clears the previous timer before setting a new one. This "reset the clock" pattern is the entire mechanic behind debounce.
👆
this binding matters
Using fn.apply(this, args) preserves the calling context. Without it, debouncing an object method would lose its reference to the object.
🧹
cancel() enables cleanup
In React, components unmount. Without cancel(), a debounced callback can fire after unmount, causing state updates on dead components — the classic memory leak.