Implement Debounce with Leading and Trailing Options
Advertisement
🧩 Scenario
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
| Mode | Fires on first call | Fires after quiet period | Use case |
|---|---|---|---|
| Trailing only (default) | No | Yes | Search input, form validation |
| Leading only | Yes | No | Button click deduplication, instant preview |
| Both (leading + trailing) | Yes | Yes (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
leadingoption correctly - Whether you know that the leading call must fire synchronously before
clearTimeout, not inside it - Whether you can add a
cancelmethod for cleanup in ReactuseEffect - Whether you can trace exact execution for a burst with both options enabled
Follow-Up Questions
- How does lodash's
_.debounceimplement themaxWaitoption, which ensures the function fires at least once every N milliseconds regardless of activity? - How would you test this debounce function with a real timer using Jest's
useFakeTimers? - What is the difference between debounce and throttle, and how would you implement throttle with a leading option?
- If you use this debounce inside a React component without
useCallback, what problem occurs on every render? - How would you expose a
flushmethod 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
setTimeoutcallback instead of synchronously before setting the timer - Forgetting to reset
timer = nullinside thesetTimeoutcallback, which breaks the leading condition for subsequent bursts - Not providing a
cancelmethod, 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
leadingoption? - Can you trace the execution for a burst of 5 calls with
leading=true, trailing=trueand state exactly when and how many times the function fires? - Can you explain why
timer === nullis the correct condition for the leading edge? - Can you add a
cancelmethod 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.
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