Write a custom debounce function in JavaScript
Debounce: delay invoking until N ms have passed since the last call. Implementation captures a timer in a closure; each call clears the prior timer and schedules a new one. Variants: leading edge (call immediately, then ignore), trailing (default), `flush` / `cancel` methods, AbortSignal support.
Basic implementation
function debounce(fn, ms) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), ms);
};
}A closure captures t. Each call clears the previous timer and schedules a new one. The function only fires after ms of quiet.
With leading + trailing edges (lodash-style)
function debounce(fn, ms, { leading = false, trailing = true } = {}) {
let t = null;
let lastArgs = null;
let lastThis = null;
let result;
function invoke() {
result = fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}
function debounced(...args) {
const callNow = leading && !t;
lastArgs = args;
lastThis = this;
if (t) clearTimeout(t);
t = setTimeout(() => {
t = null;
if (trailing && lastArgs) invoke();
}, ms);
if (callNow) invoke();
return result;
}
debounced.cancel = () => { if (t) clearTimeout(t); t = null; lastArgs = lastThis = null; };
debounced.flush = () => { if (t) { clearTimeout(t); t = null; if (lastArgs) invoke(); } };
return debounced;
}Use cases
- Input change triggering search → wait until typing pauses.
- Resize handler → wait until resize stops, then recompute.
- Save draft → wait until typing pauses, then auto-save.
Debounce vs throttle
- Debounce — wait for quiet. Last call wins.
- Throttle — at most one call per N ms. First (or scheduled) call within window wins.
Use debounce for "do this when the user stops"; throttle for "do this at most this often."
React + debounce gotchas
Don't create a new debounced function every render:
// BAD — new function per render; debounce state is reset every time
const onSearch = debounce(search, 250);
// GOOD
const onSearch = useMemo(() => debounce(search, 250), []);
useEffect(() => () => onSearch.cancel(), []); // cleanupOr use a ref:
const debouncedRef = useRef();
useEffect(() => {
debouncedRef.current = debounce(search, 250);
return () => debouncedRef.current.cancel();
}, []);AbortSignal-aware variant
function debounce(fn, ms, { signal } = {}) {
let t;
signal?.addEventListener("abort", () => clearTimeout(t), { once: true });
return (...args) => {
clearTimeout(t);
if (signal?.aborted) return;
t = setTimeout(() => fn(...args), ms);
};
}Common mistakes
- Calling
debounceinside a render → new fn every time → never debounces. - Forgetting to clean up the pending timer on unmount.
- Confusing debounce with throttle in the answer.
- Losing
thisby using arrow inside the wrapper (usefunctionifthismatters).
Interview framing
"Closure capturing the timer; each call clears the prior timer and schedules a new one — the function only fires after the configured quiet period. Useful for search input, resize handlers, autosave. Add cancel and flush methods for cleanup and flush-on-demand. Leading edge calls immediately on the first invocation in a quiet period — handy when you want a fast first response. In React, memoize the debounced function (useMemo or a ref) so a new one isn't created every render. Don't confuse with throttle — debounce waits for quiet, throttle caps frequency."
Follow-up questions
- •Compare debounce vs throttle.
- •How would you support leading + trailing?
- •Why does React render create bugs with debounce?
Common mistakes
- •Recreating debounced fn per render.
- •No cleanup on unmount.
- •Confusing debounce and throttle.
Performance considerations
- •Cheap — one setTimeout in flight. Massive win for hot handlers (resize, scroll).
Edge cases
- •Leading + trailing both fire on quick pulses.
- •Cancel during the wait.
- •this binding for methods.
Real-world examples
- •Search-as-you-type, autosave drafts, window resize handlers, scroll-driven analytics.