Swil
Frontend
Events & PerformanceJUL 09, 2026

Debounce & Throttle

Watch one keystroke stream collapse two different ways — and learn which question each answers.

Both debounce and throttle turn a flood of events into a trickle — but they answer different questions. Debounce asks “have they stopped yet?”; throttle asks “has enough time passed?”. Type into the box and watch the same keystroke stream collapse two different ways.

Livetype fast
raw 0deb 0thr 0

The top lane is every raw event. The two marked lanes are what actually ran. With debounce, nothing fires until the stream goes quiet for the full delay — so a continuous burst produces a single call at the end. With throttle, the first event fires immediately, then at most one call per interval while events keep arriving.

When to reach for which

Use debounce when you only care about the final, settled state: search-as-you-type, resize/layout recalculation, autosave, validating a field after the user stops typing. You’re willing to wait for quiet.

Use throttle when you want steady updates during a continuous action but not one per event: scroll position, mousemove/drag, pointer-driven animation, firing analytics at a bounded rate. You want responsiveness with a ceiling.

The implementation

This is the exact source the demo above runs — the leading-edge + trailing-edge throttle and the reset-on-each-call debounce, each with a cancel() so callers can tear down pending timers (React effect cleanup, component unmount).

debounceThrottle.ts
// Minimal debounce / throttle used by the live demo on this page. The source
// shown in the walkthrough IS this file, so behavior and explanation never drift.

export interface Cancelable {
  cancel: () => void;
}

/**
 * Debounce: coalesce a burst of calls into one, fired `delay` ms after the LAST
 * call. Every new call resets the timer — good for "wait until the user stops"
 * (search-as-you-type, resize settle, autosave).
 */
export function debounce<A extends unknown[]>(
  fn: (...args: A) => void,
  delay: number,
): ((...args: A) => void) & Cancelable {
  let timer: ReturnType<typeof setTimeout> | undefined;

  const debounced = (...args: A) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = undefined;
      fn(...args);
    }, delay);
  };

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

  return debounced;
}

/**
 * Throttle: allow at most one call per `interval` ms. Fires immediately on the
 * leading edge, then again on the trailing edge if calls kept coming — good for
 * "keep updating, but not too often" (scroll, mousemove, drag).
 */
export function throttle<A extends unknown[]>(
  fn: (...args: A) => void,
  interval: number,
): ((...args: A) => void) & Cancelable {
  let last = 0;
  let timer: ReturnType<typeof setTimeout> | undefined;

  const throttled = (...args: A) => {
    const now = Date.now();
    const remaining = interval - (now - last);

    if (remaining <= 0) {
      last = now;
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = undefined;
        fn(...args);
      }, remaining);
    }
  };

  throttled.cancel = () => {
    if (timer) clearTimeout(timer);
    timer = undefined;
    last = 0;
  };

  return throttled;
}