FrontendAtlas
Interview Questions
Home>JavaScript interview questions> JavaScript coding challenges >Implement debounce() in JavaScript

Implement debounce() in JavaScript

intermediate
By FrontendAtlas Editorial · Updated Jul 14, 2026
Implement debounce(fn, delay) by returning a trailing-edge wrapper that clears the previous timer and runs fn only after calls stop for delay ms. Your solution must avoid synchronous execution, keep only the latest call in a burst, preserve this and arguments, and pass the starter tests. This page includes starter code, tests, timeline examples, edge cases, and follow-ups for cancel/flush and leading behavior.

Arguments

  • fn: Function — The function to be executed after the delay.
  • delay: number — The delay in milliseconds before executing the function.

Returns

Function — A debounced version of the original function that delays execution until the delay period has elapsed.
Examples
// Example: timeline with delay=100
const log = debounce((value) => console.log(value), 100)
log('A') // t=0
// t=50: log('B') resets the timer
// t=90: log('C') resets it again
// Only C runs at t=190

// Basic example
const sayHello = debounce(() => console.log('Hello!'), 300)
sayHello()

// Multiple rapid calls — only last one runs
const log = debounce(() => console.log('Run!'), 500)
log()
log()
log() // Only this one executes after 500ms

// With arguments
const greet = debounce(name => console.log(`Hi ${name}`), 400)
greet('Alice')
greet('Bob') // Only 'Hi Bob' logs after 400ms

Solution

Overview

Interview answer: the base prompt is a trailing-edge debounce with one timeoutId; cancel(), flush(), and leading are follow-ups.

Goal: ensure fn runs only after calls have stopped for delay ms. This is common for search inputs, resize/scroll handlers, and deduping rapid UI events. Approaches 2 and 3 show common extensions used in real-world libraries.

1

Approach 1: Timeout-based debounce (canonical)

Idea: store a timeoutId. Each call clears the previous timeout and schedules a new one. When the last timer completes, invoke fn with the latest args and this.

How it works:

  1. Keep let timeoutId in the closure.
  2. On each call, clear any existing timer with clearTimeout(timeoutId).
  3. Start a new timer: timeoutId = setTimeout(() => fn.apply(this, args), delay).
  4. Return the debounced wrapper function.

This is the classic trailing-edge debounce: only the last call in a burst actually runs.

export default function debounce(fn, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}
export default function debounce<F extends (...a: any[]) => void>(fn: F, delay: number): F {
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  return function (this: any, ...args: Parameters<F>) {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  } as F;
}
2

Approach 2: Debounce with cancel + flush helpers (extended API)

Idea: extend the basic debounced function with .cancel() and .flush() helpers so callers can cancel a pending call or force it to run immediately.

How it works:

  1. Wrap the debounced function and keep timeoutId, lastArgs, and lastThis in the closure.
  2. On each call, store the latest args/context, clear the previous timer, and start a new one.
  3. .cancel() just clears the timer.
  4. .flush() clears the timer and immediately invokes fn with the last stored args and context.

This pattern mirrors popular utility libraries (like Lodash) and is an extension beyond the minimal debounce(fn, delay) contract.

export default function debounce(fn, delay) {
  let timeoutId, lastArgs, lastThis;
  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      timeoutId = undefined;
      fn.apply(lastThis, lastArgs);
    }, delay);
  }
  debounced.cancel = () => {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
    }
  };
  debounced.flush = () => {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      fn.apply(lastThis, lastArgs);
    }
  };
  return debounced;
}
type Debounced<F extends (...a: any[]) => void> = F & { cancel: () => void; flush: () => void };

export default function debounce<F extends (...a: any[]) => void>(fn: F, delay: number): Debounced<F> {
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  let lastArgs: Parameters<F>;
  let lastThis: any;

  const debounced = function (this: any, ...args: Parameters<F>) {
    lastArgs = args;
    lastThis = this;
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(() => {
      timeoutId = undefined;
      fn.apply(lastThis, lastArgs);
    }, delay);
  } as Debounced<F>;

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

  debounced.flush = () => {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      fn.apply(lastThis, lastArgs);
    }
  };

  return debounced;
}
3

Approach 3: Leading-edge option (extended signature)

Idea: add an options object to support leading mode. With leading: true, call fn immediately on the first trigger in a burst and then suppress further calls until the delay elapses. With leading: false (default), fall back to classic trailing-only debounce.

How it works:

  1. Track timeoutId, plus the last arguments and this.
  2. On each call, compute shouldCallNow = leading && !timeoutId to know whether this should be an immediate (leading) call.
  3. Clear any existing timer and start a new one. In trailing mode (leading === false), the timer will call fn after delay with the last arguments. In leading mode, the timer simply resets timeoutId after delay so that a new burst can trigger another leading call.
  4. If shouldCallNow is true, invoke fn immediately with the current arguments.

This is an API extension: the base exercise only requires debounce(fn, delay), but this shows how to evolve it to support configurable leading behavior.

export default function debounce(fn, delay, { leading = false } = {}) {
  let timeoutId, lastArgs, lastThis;
  return function (...args) {
    lastArgs = args;
    lastThis = this;
    const shouldCallNow = leading && !timeoutId;

    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      timeoutId = undefined;
      if (!leading) {
        fn.apply(lastThis, lastArgs);
      }
    }, delay);

    if (shouldCallNow) {
      fn.apply(lastThis, lastArgs);
    }
  };
}
export default function debounce<F extends (...a: any[]) => void>(
  fn: F,
  delay: number,
  opts: { leading?: boolean } = {}
): F {
  const { leading = false } = opts;
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  let lastArgs: Parameters<F>;
  let lastThis: any;

  const debounced = function (this: any, ...args: Parameters<F>) {
    lastArgs = args;
    lastThis = this;
    const shouldCallNow = leading && !timeoutId;

    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      timeoutId = undefined;
      if (!leading) {
        fn.apply(lastThis, lastArgs);
      }
    }, delay);

    if (shouldCallNow) {
      fn.apply(lastThis, lastArgs);
    }
  } as F;

  return debounced;
}

Notes & Pitfalls

Pitfalls
  • Return value is usually lost; debounced wrappers typically return `void`. If you need a result, consider callbacks or promises.
  • Be careful with `this` — use `fn.apply(this, args)` (or `fn.call`) so methods still see the correct context.
  • Timers differ between environments (Node vs browser), but `setTimeout/clearTimeout` work in both.
Edge cases
  • delay = 0 → effectively batches multiple calls into one at the end of the event loop.
  • Rapid continuous calls → only the last invocation executes after inactivity in trailing mode.
  • In long-lived components, remember to cancel timers (e.g. via `.cancel()`) on unmount to avoid stray executions.
Techniques
  • Using closures to store timer and last-arguments state.
  • Clearing and resetting timeouts to restart the debounce window.
  • Extending the basic pattern with cancel/flush helpers and leading-edge options.

Common interview follow-ups:

  • How would you add `.cancel()` so pending work can be cleared on unmount?
  • How would you add `.flush()` so the latest pending call runs immediately?
  • How would the implementation change for leading-edge debounce?

Resources

  • MDN – setTimeout()
  • MDN – Function.prototype.apply()

Related links

Implement throttle() in JavaScriptCodingReact debounced search coding challengeReactAngular RxJS debounced search challengeAngularDesign realtime search with debounce and cacheSystem designJavaScript coding interview questionsGuide

Guides

Frontend interview preparation guideGuideFrontend coding interview questions and prep guideBlueprintJavaScript Problems That Actually Show UpBlueprintBuild Great UI in 60 MinutesBlueprint

Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.

Open frontend interview questionsBrowse JavaScript interview questionsOpen Essential 60Open Machine Coding HubOpen Frontend Coding ChallengesOpen System DesignOpen Interview Prep GuideOpen System Design BlueprintOpen Framework Prep PathsOpen Study PlansOpen JavaScript mastery study planOpen Company PrepOpen JavaScript Framework Prep Guide
↗Incidents hub
7 / 88