Debounce Implementation
Implement debounce in JavaScript — trailing vs leading, cancel/flush, TypeScript typing, and when to throttle instead.
- javascript
- debounce
- performance
- interview
Search boxes fire input events on every keystroke. Resize and scroll fire dozens of times per second. If each event hits the network or forces layout, the UI stutters and your API melts.
Debounce waits until calls stop for N milliseconds, then runs the function once with the latest arguments. Throttle runs at most once per window while calls continue. Different tools.
Interviewers ask you to implement debounce because it tests closures, this, timers, and API design — not because they want Lodash trivia.
Spec (what “correct” means)
Given debounce(fn, wait):
- Each call to the returned function resets the timer.
- When
waitms pass with no new calls, invokefnwith the lastthisand arguments. - Optional:
leadingedge (run immediately, then ignore until quiet),cancel(),flush().
function debounce(fn, wait) {
let timerId = null;
return function debounced(...args) {
const context = this;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
fn.apply(context, args);
}, wait);
};
}
const onSearch = debounce((q) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`);
}, 300);
input.addEventListener('input', (e) => onSearch(e.target.value));
Type “reac” quickly → one request for "reac", not four.
Why this and apply matter
If fn is a method, callers expect the right this:
const client = {
base: '/api',
search(q) {
return fetch(`${this.base}/search?q=${encodeURIComponent(q)}`);
},
};
// Preserve method this when used as client.searchDebounced('x')
client.searchDebounced = debounce(client.search, 300);
Arrows as the outer wrapper would freeze this wrong; the implementation above uses a plain function so this comes from the call site of debounced.
Leading vs trailing
| Mode | Behavior | Use |
|---|---|---|
| Trailing (default) | Fire after quiet period | Search-as-you-type, form validation |
| Leading | Fire on first call, then cool down | Buttons (“submit once”), some resize open |
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timerId = null;
let lastArgs;
let lastThis;
let tookLeading = false;
function invoke() {
const args = lastArgs;
const ctx = lastThis;
lastArgs = lastThis = undefined;
return fn.apply(ctx, args);
}
function debounced(...args) {
lastArgs = args;
lastThis = this;
const isFirst = timerId == null;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
if (trailing && lastArgs) invoke();
tookLeading = false;
}, wait);
if (leading && isFirst && !tookLeading) {
tookLeading = true;
return invoke();
}
}
debounced.cancel = () => {
clearTimeout(timerId);
timerId = null;
lastArgs = lastThis = undefined;
tookLeading = false;
};
debounced.flush = () => {
if (timerId && lastArgs) {
clearTimeout(timerId);
timerId = null;
return invoke();
}
};
return debounced;
}
Edge cases worth testing:
wait = 0still async (macrotask); not the same as sync callcancelon unmount so a late timer doesn’tsetStateon a dead component- Leading + trailing together can fire twice per burst if you’re not careful — define product behavior explicitly
TypeScript sketch
type Debounced<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => void) & {
cancel: () => void;
flush: () => ReturnType<T> | undefined;
};
function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number
): Debounced<T> {
let timerId: ReturnType<typeof setTimeout> | null = null;
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const context = this;
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
fn.apply(context, args);
}, wait);
} as Debounced<T>;
debounced.cancel = () => {
if (timerId) clearTimeout(timerId);
timerId = null;
};
debounced.flush = () => undefined; // extend as needed
return debounced;
}
React usage (cleanup)
function SearchBox({ onQuery }) {
const debouncedRef = useRef(
debounce((value) => {
onQuery(value);
}, 300)
);
useEffect(() => {
const d = debouncedRef.current;
return () => d.cancel();
}, []);
return (
<input
aria-label="Search"
onChange={(e) => debouncedRef.current(e.target.value)}
/>
);
}
Don’t create a new debounce(...) every render without a ref/memo — you’ll never reset the same timer.
See useEffect Fundamentals for abort/cancel on unmount.
Debounce vs throttle
function throttle(fn, wait) {
let last = 0;
let timerId = null;
let lastArgs;
let lastThis;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
lastArgs = args;
lastThis = this;
if (remaining <= 0) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
last = now;
fn.apply(lastThis, lastArgs);
} else if (!timerId) {
// trailing call optional
timerId = setTimeout(() => {
last = Date.now();
timerId = null;
fn.apply(lastThis, lastArgs);
}, remaining);
}
};
}
| Debounce | Throttle | |
|---|---|---|
| Continuous events | Waits for pause | Steady cadence |
| Search input | Usually better | Can spam mid-typing |
| Scroll position UI | Feels laggy | Often better |
Interview angle
Write trailing debounce in ~10 lines, then add cancel. Explain closure over timerId. Mention leading edge and React unmount. Complexity: O(1) per call; timer scheduling is the cost.
Common fail: storing args wrong so only the first keystroke is used — always overwrite args on each call.
Related on this site
- The Event Loop —
setTimeoutis a macrotask - Closures — how
timerIdpersists - this Binding Rules — why
applyappears - useEffect Fundamentals — cancel on unmount
- Event Delegation — high-frequency DOM events
Further reading
- MDN: setTimeout
- CSS-Tricks: Debouncing and Throttling (concept overview)
- Lodash debounce docs — full options reference, not required to reinvent
Related guides
- Throttle ImplementationImplement throttle in JavaScript — leading vs trailing edges, cancel, comparison with debounce, React cleanup, and interview-ready code.
- DocumentFragmentBuild subtrees off-DOM with DocumentFragment — one insert, fewer reflows, and how it differs from a wrapper div.
- Garbage Collection Mental ModelReachability, mark-and-sweep, retained closures and DOM — a practical GC model for frontend engineers debugging memory.
- IntersectionObserverObserve element visibility asynchronously — lazy images, infinite scroll, ad viewability, without scroll listener jank.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.