Implement Debounce with Leading and Trailing Options

Intermediate15 min interview
Skills tested:
Implementing trailing-edge debounce from scratch using clearTimeout and setTimeoutImplementing leading-edge debounce that fires on the first call then suppresses until quietCombining both leading and trailing options in a single implementationWhy timer === null is the correct check for the leading-edge conditionReturning a cancel method to allow callers to discard pending trailing calls

Advertisement

🧩 Scenario

In a real codebase, the trailing-only debounce handles search inputs and form validation. The leading-only debounce prevents double-submit on payment buttons: the first click fires immediately, and subsequent rapid clicks during the delay are ignored. The combined mode provides instant feedback on first action plus a final settled call after the burst ends.

Architecture Walkthrough

Trailing-Edge Debounce: Wait for Silence

A trailing-edge debounce delays the wrapped function until the calling pattern has been quiet for the specified delay. Every new call resets the timer: clearTimeout cancels the pending call and setTimeout schedules a fresh one. The wrapped function only executes when a timer completes without being interrupted.

The closure over timer is the entire mechanism. timer tracks whether a pending call exists. On each invocation, clear the old timer and set a new one. When the timer fires, invoke the wrapped function and reset timer to null so the next burst can start fresh.

Leading-Edge Debounce: Fire First, Then Suppress

A leading-edge debounce fires the wrapped function immediately on the first call of a burst, then suppresses subsequent calls until the burst ends. The condition for "first call of a burst" is timer === null: no pending timer means no ongoing burst.

The timer is set without a callback (or with a callback that only resets the timer) to suppress calls during the delay window. When the window expires, timer is reset to null so the next burst can fire on its leading edge again.

Combining Both Options

With both leading and trailing enabled, the function fires twice in one burst: once immediately on the first call (leading) and once after the burst ends (trailing). If the entire burst produces only one call, the leading call fires but the trailing call is suppressed (to avoid firing the function twice for a single invocation).

This combined mode is useful for interfaces that need both instant feedback and a final settled call: a search input that shows an immediate "searching..." indicator (leading) and then performs the actual search after typing stops (trailing).


Key Code Explained

function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timer = null;
  let leadingCalled = false;

  function debounced(...args) {
    // Leading: fire on the first call of a burst (when timer is null)
    if (leading && timer === null) {
      fn.apply(this, args);
      leadingCalled = true;
    } else {
      leadingCalled = false;
    }

    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;

      // Trailing: fire after quiet period, but skip if leading already fired
      // for a single-call burst (avoid double-firing)
      if (trailing && !leadingCalled) {
        fn.apply(this, args);
      }
      leadingCalled = false;
    }, delay);
  }

  // Allow callers to discard any pending trailing call
  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
    leadingCalled = false;
  };

  return debounced;
}

// Trailing only (default): fires after typing stops
const searchTrailing = debounce((q) => fetchResults(q), 300);
// Input: 'r', 're', 'rea', 'reac', 'react' → fires once with 'react'

// Leading only: fires immediately, suppresses until quiet
const searchLeading = debounce((q) => fetchResults(q), 300, {
  leading: true,
  trailing: false,
});
// Input: 'r', 're', 'rea', 'reac', 'react' → fires immediately with 'r', done

// Both: immediate feedback + settled call
const searchBoth = debounce((q) => fetchResults(q), 300, {
  leading: true,
  trailing: true,
});
// Burst of 5 inputs → fires with 'r' immediately, then fires with 'react' after quiet
// Single input → fires once with leading (no trailing for single-call burst)

// React: prevent double-submit on payment button
const submitPayment = useCallback(
  debounce(
    async (formData) => {
      await processPayment(formData);
    },
    2000,
    { leading: true, trailing: false },
  ),
  [],
);

// Cleanup on unmount
useEffect(() => {
  return () => submitPayment.cancel();
}, [submitPayment]);

The leadingCalled flag is the mechanism for preventing double-firing in combined mode for a single-call burst. If the leading call fires (leadingCalled = true) and the timer completes without any subsequent calls during the delay (meaning the burst had only one event), the trailing call is suppressed because leadingCalled is still true when the timer fires.


Tradeoffs

ModeFires on first callFires after quiet periodUse case
Trailing only (default)NoYesSearch input, form validation
Leading onlyYesNoButton click deduplication, instant preview
Both (leading + trailing)YesYes (if burst > 1 call)Instant feedback with final settled result

What Interviewers Actually Check

  • Whether you can implement trailing-edge debounce from memory including the timer reset
  • Whether you can extend it to support the leading option correctly
  • Whether you know that the leading call must fire synchronously before clearTimeout, not inside it
  • Whether you can add a cancel method for cleanup in React useEffect
  • Whether you can trace exact execution for a burst with both options enabled

Follow-Up Questions

  1. How does lodash's _.debounce implement the maxWait option, which ensures the function fires at least once every N milliseconds regardless of activity?
  2. How would you test this debounce function with a real timer using Jest's useFakeTimers?
  3. What is the difference between debounce and throttle, and how would you implement throttle with a leading option?
  4. If you use this debounce inside a React component without useCallback, what problem occurs on every render?
  5. How would you expose a flush method that immediately invokes the pending trailing call and cancels the timer?

Common Candidate Mistakes

  • Implementing only the trailing variant and not knowing the leading variant is a separate concept
  • Placing the leading call inside the setTimeout callback instead of synchronously before setting the timer
  • Forgetting to reset timer = null inside the setTimeout callback, which breaks the leading condition for subsequent bursts
  • Not providing a cancel method, leaving no way to clean up a pending timer on component unmount
  • Not knowing that with both options enabled, a burst of N calls fires twice (leading + trailing), but a single call fires only once (leading)

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you implement trailing-edge debounce from memory with correct timer management?
  • Can you extend the implementation to support the leading option?
  • Can you trace the execution for a burst of 5 calls with leading=true, trailing=true and state exactly when and how many times the function fires?
  • Can you explain why timer === null is the correct condition for the leading edge?
  • Can you add a cancel method that clears the pending timer?

Summary

Debounce delays a function until a burst of calls has been quiet for a specified duration. The trailing-edge variant (the default) fires once after the burst ends. The leading-edge variant fires immediately on the first call of a burst and then suppresses all subsequent calls during the quiet window.

The implementation uses a single timer variable closed over by the returned function. For trailing: clear the old timer and set a new one on every call; invoke the wrapped function when the timer fires. For leading: fire synchronously when timer === null (no ongoing burst) before setting a suppression timer; the timer itself does not invoke the function but does reset the timer to null when it expires.

With both options enabled, the function fires on the leading edge and again after the burst ends, but only once for a single isolated call. The cancel method allows callers (typically React useEffect cleanup) to discard a pending trailing call, which is essential for preventing stale state updates after component unmount.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

When should you use leading debounce?

When you want the first event to trigger immediately for fast feedback, such as instant search or button click prevention. Trailing fires after the burst ends.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement