Back to JavaScript
JavaScript
medium
very high
mid

How would you implement a debounce function from scratch?

Return a function that resets a timer on every call and only invokes fn after `wait` ms of silence. Forward `this` and arguments, expose cancel/flush, and optionally support a leading-edge call.

6 min read·~20 min to think through

Debounce is the most-asked utility in frontend interviews because it tests four things at once: closures, this binding, timers, and API design. The naive answer (setTimeout in a closure) gets you 60% credit; the rest is what a senior adds — this forwarding, cancel/flush, leading vs trailing, and edge cases.

Definition. A debounced function delays invoking fn until wait ms have elapsed since the last call. Useful for: search-as-you-type, autosave, resize handlers, expensive recalculation on scroll.

Trailing-edge (default) implementation. Hold the timer in closure. Each invocation clears the previous and schedules a new one.

this and arguments forwarding. Because the user might use the debounced function as a method (obj.handler = debounce(fn, 200)) or pass it as a DOM event handler, you must use a function expression (not arrow) and call fn.apply(this, args) so the original this and the latest args are preserved.

Cancel. Expose .cancel() to drop the pending invocation — important for cleanup in React's useEffect return, or when the user navigates away.

Flush. .flush() invokes the pending call immediately and clears the timer — useful when a form submits and you want the latest debounced value applied right now.

Leading edge. With { leading: true } the first call fires immediately, then subsequent calls within wait are suppressed; the trailing call fires after silence. Common pattern for "only fire once per burst."

Debounce vs throttle. Debounce waits for silence; throttle guarantees one call per interval. Use debounce when you only care about the final value (search), throttle when you need steady updates (scroll-position UI).

React gotcha. Don't debounce(fn, 200) inline in render — every render produces a new debounced function and the timer never accumulates. Wrap in useMemo (or use useDebouncedCallback from use-debounce) and call .cancel() in the cleanup.

Code

ts
type Debounced<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & {
  cancel: () => void;
  flush: () => void;
};

export function debounce<F extends (...args: any[]) => any>(
  fn: F,
  wait: number,
  options: { leading?: boolean } = {}
): Debounced<F> {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let lastArgs: Parameters<F> | null = null;
  let lastThis: unknown = null;

  const invoke = () => {
    if (lastArgs) fn.apply(lastThis, lastArgs);
    lastArgs = null;
    lastThis = null;
    timer = null;
  };

  const debounced = function (this: unknown, ...args: Parameters<F>) {
    lastArgs = args;
    lastThis = this;
    const callNow = options.leading && timer === null;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!options.leading) invoke();
    }, wait);
    if (callNow) invoke();
  } as Debounced<F>;

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

  debounced.flush = () => {
    if (timer) {
      clearTimeout(timer);
      invoke();
    }
  };

  return debounced;
}
Production-grade debounce: this-forwarding, cancel, flush, leading option
tsx
function Search({ onQuery }: { onQuery: (q: string) => void }) {
  const debounced = useMemo(() => debounce(onQuery, 250), [onQuery]);
  useEffect(() => () => debounced.cancel(), [debounced]);
  return <input onChange={e => debounced(e.target.value)} />;
}
Using it in a React search input

Follow-up questions

  • Implement throttle — how does it differ?
  • How would you support both leading and trailing options together?
  • Why does debouncing a method without preserving `this` break?
  • How does lodash.debounce handle maxWait?

Common mistakes

  • Using an arrow function for the returned wrapper — `this` is lost.
  • Forgetting to capture `args` in the closure (using outer `arguments`).
  • Calling `debounce` inline in JSX — new instance every render, timer never accumulates.
  • Not exposing cancel — leaks pending timers when the component unmounts.

Performance considerations

  • Debouncing avoids unnecessary work but adds latency equal to `wait`. Pick wait based on UX (250ms feels instant for search).
  • For very high-frequency events, throttle keeps the UI smoother than debounce.

Edge cases

  • wait = 0 still defers via setTimeout — useful to coalesce within a tick.
  • Calling cancel after flush is a no-op (timer is already cleared).
  • If `fn` throws, the timer is cleared but the error propagates synchronously from the timer callback.

Real-world examples

  • Lodash's _.debounce, the use-debounce React hook, and most search inputs in production apps.

Senior engineer discussion

Senior signal: this-forwarding via function expression, cancel/flush API, leading/trailing options, and React-specific lifecycle handling.