Debounce and Throttle Implementations
Key Learning Objectives
Distinguish between debouncing (delaying execution until inactivity) and throttling (enforcing a fixed maximum execution frequency).
Implement production-grade debounce with trailing edge, immediate leading-edge execution, and cancellation.
Implement throttle using timer locks and timestamp differences, preserving execution context and arguments.
Avoid memory leaks by properly canceling pending timers during component unmounting or navigation.
The Interview Problem
What is logged to the console when the following code executes, and how do debounce cancellation and leading-edge throttling alter callback dispatch order?
1function debounce(fn, delay) {2 let timerId = null;3 return function (...args) {4 if (timerId) clearTimeout(timerId);5 timerId = setTimeout(() => {6 fn.apply(this, args);7 timerId = null;8 }, delay);9 };10}1112function throttle(fn, limit) {13 let inThrottle = false;14 return function (...args) {15 if (!inThrottle) {16 fn.apply(this, args);17 inThrottle = true;18 setTimeout(() => {19 inThrottle = false;20 }, limit);21 }22 };23}2425const logs = [];26const logPush = (msg) => logs.push(msg);2728const debounced = debounce((v) => logPush(`deb:${v}`), 50);29const throttled = throttle((v) => logPush(`thr:${v}`), 50);3031throttled('1');32throttled('2');33debounced('A');34debounced('B');35logPush('sync-end');3637setTimeout(() => {38 console.log(logs.join(' '));39}, 100);
Predict Console Output
Select the option that matches what standard ECMAScript prints to the console:
thr:1 sync-end deb:B
sync-end thr:1 deb:A deb:B
thr:1 thr:2 sync-end deb:B
thr:1 sync-end deb:A deb:B
V8 Engine Execution Trace
Step 1 of 6 (Line 29)throttled('1') is invoked. inThrottle is initially false, so fn.apply(this, args) executes synchronously, logging 'thr:1'. inThrottle is flipped to true and a timer is enqueued.
Deep Technical Breakdown
Debounce vs Throttle: Mental Model & Differences
Both debounce and throttle are rate-limiting techniques that optimize event handling, but they solve different problems:
- Debounce (Trailing Edge by Default):
- Concept: Groups a burst of events into a single execution. The timer resets on every trigger. The function only executes once a specified quiet period (delay) has passed without any new events.
- Common Use Cases: Search input autocomplete, form field validation on keystroke, window resize recalculations.
- Throttle (Regular Intervals):
- Concept: Guarantees that the function executes at most once per time window (e.g., once every 100ms), regardless of how frequently the user triggers events.
- Common Use Cases: Scroll position tracking, infinite scroll triggers, mousemove/pointermove canvas drawing, game loop rendering.
Production Implementations
1. Debounce with Immediate (Leading) & Trailing Support + Cancel
function debounce(fn, wait, options = { leading: false, trailing: true }) {
let timerId = null;
let lastArgs = null;
let lastThis = null;
function debounced(...args) {
lastArgs = args;
lastThis = this;
const isFirstCall = !timerId && options.leading;
if (timerId) {
clearTimeout(timerId);
}
if (isFirstCall) {
fn.apply(lastThis, lastArgs);
}
timerId = setTimeout(() => {
if (options.trailing && !isFirstCall) {
fn.apply(lastThis, lastArgs);
}
timerId = null;
lastArgs = null;
lastThis = null;
}, wait);
}
debounced.cancel = () => {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
return debounced;
}2. Throttle with Timestamp or Timer Lock
function throttle(fn, limit) {
let lastCall = 0;
let timerId = null;
return function (...args) {
const now = Date.now();
const remaining = limit - (now - lastCall);
if (remaining <= 0) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
lastCall = now;
fn.apply(this, args);
} else if (!timerId) {
timerId = setTimeout(() => {
lastCall = Date.now();
timerId = null;
fn.apply(this, args);
}, remaining);
}
};
}Memory Leak Prevention
In modern SPAs (React, Vue), debounced functions retain closures containing component state. Always call debounced.cancel() or clearTimeout in cleanup effects (useEffect return or componentWillUnmount) to prevent stale closures and memory leaks.
Common Traps & Mistakes
Losing execution context (`this`) by passing `() => fn(...args)` without binding or using `fn.apply(this, args)` when the debounced function is an object method.
Forgetting to clear timeouts on component unmount, causing 'Can't perform a React state update on an unmounted component' warnings or memory retention.
Recreating the debounced/throttled function on every component render rather than wrapping it in `useCallback` or `useRef`.
Confusing debounce with throttle during interview questions when asked about search input versus infinite scroll.
FAANG Follow-Up Probes
Probe #1
How would you implement a `flush()` method that immediately executes any pending debounced callback and cancels its timer?
Probe #2
How does `requestAnimationFrame` compare to `throttle` for 60fps UI animations like scroll and drag listeners?
Probe #3
Why does creating a debounced function directly inside a React component body fail unless wrapped in `useCallback` or `useMemo`?
