ESC

Type to search the knowledge base.

Debounce Implementation

Implement debounce in JavaScript — trailing vs leading, cancel/flush, TypeScript typing, and when to throttle instead.

intermediate5 min read
  • javascript
  • debounce
  • performance
  • interview

Search boxes fire input events on every keystroke. Resize and scroll fire dozens of times per second. If each event hits the network or forces layout, the UI stutters and your API melts.

Debounce waits until calls stop for N milliseconds, then runs the function once with the latest arguments. Throttle runs at most once per window while calls continue. Different tools.

Interviewers ask you to implement debounce because it tests closures, this, timers, and API design — not because they want Lodash trivia.

Spec (what “correct” means)

Given debounce(fn, wait):

  1. Each call to the returned function resets the timer.
  2. When wait ms pass with no new calls, invoke fn with the last this and arguments.
  3. Optional: leading edge (run immediately, then ignore until quiet), cancel(), flush().
function debounce(fn, wait) {
  let timerId = null;

  return function debounced(...args) {
    const context = this;

    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(context, args);
    }, wait);
  };
}
const onSearch = debounce((q) => {
  fetch(`/api/search?q=${encodeURIComponent(q)}`);
}, 300);

input.addEventListener('input', (e) => onSearch(e.target.value));

Type “reac” quickly → one request for "reac", not four.

Why this and apply matter

If fn is a method, callers expect the right this:

const client = {
  base: '/api',
  search(q) {
    return fetch(`${this.base}/search?q=${encodeURIComponent(q)}`);
  },
};

// Preserve method this when used as client.searchDebounced('x')
client.searchDebounced = debounce(client.search, 300);

Arrows as the outer wrapper would freeze this wrong; the implementation above uses a plain function so this comes from the call site of debounced.

Leading vs trailing

Mode Behavior Use
Trailing (default) Fire after quiet period Search-as-you-type, form validation
Leading Fire on first call, then cool down Buttons (“submit once”), some resize open
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
  let timerId = null;
  let lastArgs;
  let lastThis;
  let tookLeading = false;

  function invoke() {
    const args = lastArgs;
    const ctx = lastThis;
    lastArgs = lastThis = undefined;
    return fn.apply(ctx, args);
  }

  function debounced(...args) {
    lastArgs = args;
    lastThis = this;

    const isFirst = timerId == null;

    clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      if (trailing && lastArgs) invoke();
      tookLeading = false;
    }, wait);

    if (leading && isFirst && !tookLeading) {
      tookLeading = true;
      return invoke();
    }
  }

  debounced.cancel = () => {
    clearTimeout(timerId);
    timerId = null;
    lastArgs = lastThis = undefined;
    tookLeading = false;
  };

  debounced.flush = () => {
    if (timerId && lastArgs) {
      clearTimeout(timerId);
      timerId = null;
      return invoke();
    }
  };

  return debounced;
}

Edge cases worth testing:

  • wait = 0 still async (macrotask); not the same as sync call
  • cancel on unmount so a late timer doesn’t setState on a dead component
  • Leading + trailing together can fire twice per burst if you’re not careful — define product behavior explicitly

TypeScript sketch

type Debounced<T extends (...args: any[]) => any> = ((
  ...args: Parameters<T>
) => void) & {
  cancel: () => void;
  flush: () => ReturnType<T> | undefined;
};

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

  const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
    const context = this;
    if (timerId) clearTimeout(timerId);
    timerId = setTimeout(() => {
      timerId = null;
      fn.apply(context, args);
    }, wait);
  } as Debounced<T>;

  debounced.cancel = () => {
    if (timerId) clearTimeout(timerId);
    timerId = null;
  };

  debounced.flush = () => undefined; // extend as needed

  return debounced;
}

React usage (cleanup)

function SearchBox({ onQuery }) {
  const debouncedRef = useRef(
    debounce((value) => {
      onQuery(value);
    }, 300)
  );

  useEffect(() => {
    const d = debouncedRef.current;
    return () => d.cancel();
  }, []);

  return (
    <input
      aria-label="Search"
      onChange={(e) => debouncedRef.current(e.target.value)}
    />
  );
}

Don’t create a new debounce(...) every render without a ref/memo — you’ll never reset the same timer.

See useEffect Fundamentals for abort/cancel on unmount.

Debounce vs throttle

function throttle(fn, wait) {
  let last = 0;
  let timerId = null;
  let lastArgs;
  let lastThis;

  return function (...args) {
    const now = Date.now();
    const remaining = wait - (now - last);
    lastArgs = args;
    lastThis = this;

    if (remaining <= 0) {
      if (timerId) {
        clearTimeout(timerId);
        timerId = null;
      }
      last = now;
      fn.apply(lastThis, lastArgs);
    } else if (!timerId) {
      // trailing call optional
      timerId = setTimeout(() => {
        last = Date.now();
        timerId = null;
        fn.apply(lastThis, lastArgs);
      }, remaining);
    }
  };
}
Debounce Throttle
Continuous events Waits for pause Steady cadence
Search input Usually better Can spam mid-typing
Scroll position UI Feels laggy Often better

Interview angle

Write trailing debounce in ~10 lines, then add cancel. Explain closure over timerId. Mention leading edge and React unmount. Complexity: O(1) per call; timer scheduling is the cost.

Common fail: storing args wrong so only the first keystroke is used — always overwrite args on each call.

Further reading

Related guides