Back to JavaScript
JavaScript
easy
mid

How would you implement a basic debounce function without using any library?

A closure over a timer id: each call clears the pending timeout and schedules a new one, so the function runs only after calls stop for the wait period. Preserve this/arguments with apply, and add a cancel method for cleanup.

5 min read·~10 min to think through

Debounce delays a function until calls stop for wait ms — every new call resets the timer.

The implementation

js
function debounce(fn, wait) {
  let timeoutId;                          // closure-held timer id

  return function (...args) {
    clearTimeout(timeoutId);              // cancel any pending call
    timeoutId = setTimeout(() => {
      fn.apply(this, args);               // run later, preserving this + args
    }, wait);
  };
}

How it works:

  1. debounce returns a new function that closes over timeoutId.
  2. Each call clears the previous pending timeout and schedules a new one.
  3. fn only fires when calls stop for wait ms — every call within the window pushes it back.
  4. fn.apply(this, args) forwards the call context and arguments correctly.

Usage

js
const onResize = debounce(() => console.log("resized"), 200);
window.addEventListener("resize", onResize);

const search = debounce((q) => fetchResults(q), 300);
input.addEventListener("input", (e) => search(e.target.value));

Add a cancel method (the senior touch)

Important so you can clean up a pending call (e.g. on React unmount):

js
function debounce(fn, wait) {
  let timeoutId;
  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), wait);
  }
  debounced.cancel = () => clearTimeout(timeoutId);
  return debounced;
}

Things to get right (and mention)

  • this / arguments — use a regular function + apply, not an arrow, so the caller's this is preserved. (...args captures arguments.)
  • ClosuretimeoutId must persist across calls; it lives in the closure, one per debounced function.
  • cancel() — needed for cleanup.
  • Optional extensions: a leading option (fire on the first call, then debounce), trailing (the default), or returning the result via a promise.

Debounce vs throttle (clarify if asked)

  • Debounce — runs once after calls stop. Search input, autosave, resize-settled.
  • Throttle — runs at most once per interval during continuous calls. Scroll, mousemove, drag.

How to answer

"Return a function that closes over a timer id; each call clears the pending timeout and sets a new one, so fn runs only after calls quiet down for wait ms. Use a regular function with apply to preserve this and arguments, and attach a cancel method so a pending call can be cleared on cleanup."

Follow-up questions

  • Why use a regular function and apply instead of an arrow function here?
  • Why does the debounced function need a cancel method?
  • How would you add a leading-edge option?
  • How is this different from throttle?

Common mistakes

  • Using an arrow function for the returned function, losing the caller's this.
  • Declaring timeoutId inside the returned function — it resets every call, so it never debounces.
  • Not providing a cancel method for cleanup.
  • Confusing debounce with throttle.

Performance considerations

  • Debounce collapses a burst of events into one execution — essential for API calls, expensive computations, and resize/scroll handlers. The wait value trades responsiveness against work done.

Edge cases

  • Caller relies on this — must be preserved via apply.
  • Pending call when the consumer needs to clean up (cancel).
  • leading + trailing behavior.
  • Very rapid calls that should still result in exactly one execution.

Real-world examples

  • Search-as-you-type, autosave-on-pause, resize handlers, form validation on input-settled.

Senior engineer discussion

Seniors write it cleanly with a closure-held timer, correctly preserve this/arguments with a regular function + apply, and add cancel for cleanup — then mention leading/trailing options and contrast debounce vs throttle by use case. They know the classic bug: declaring the timer id in the wrong scope.

Related questions