Implement Debounce and Throttle
The 300ms That Cost $3 Million
A search autocomplete fires a network request on every keystroke. A user types "react hooks" at normal speed -- that's 11 characters in about 2 seconds. Without debounce, that's 11 API calls, 11 response parses, 11 DOM updates. Multiply by 50,000 concurrent users and your API is toast.
On the flip side, a resize handler recalculates a complex layout 60 times per second while the user drags a window edge. Without throttle, you're doing layout calculations every 16ms when once every 200ms would look identical.
Debounce and throttle are the two most fundamental rate-limiting patterns in frontend engineering. Every FAANG interview expects you to implement both from scratch. Here's the thing most candidates miss: the basic version takes 5 lines, but a production-grade version with leading, trailing, maxWait, cancel, and flush takes real engineering.
The Mental Model
Debounce is like an elevator door. Every time someone walks up (new call), the door-close timer resets. The door only closes (function fires) after nobody has arrived for a set period. If people keep arriving, the door stays open indefinitely.
Throttle is like a turnstile. It lets one person through (one function call) per time interval. Others who arrive during the cooldown period are ignored or queued. The turnstile opens again after the interval passes, regardless of how many people are waiting.
Debounce vs Throttle at a Glance
| Debounce | Throttle | |
|---|---|---|
| When it fires | After a pause in calls | At most once per interval |
| Resets timer on new call? | Yes -- restarts the wait | No -- interval is fixed |
| Best for | Search input, form validation, resize end | Scroll handler, mousemove, game input |
| If called 100x in 1s (200ms delay) | Fires once, 200ms after the last call | Fires 5 times (every 200ms) |
| Guarantees execution? | Only after calls stop | At regular intervals during activity |
| Can delay indefinitely? | Yes, if calls never stop (without maxWait) | No -- fires every interval |
Building Debounce From Scratch
Level 1: The Minimal Version
This is what most candidates write in interviews. It works, but it's incomplete:
function debounce(func, wait) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
Here's what's happening:
- We close over
timeoutIdto persist it across calls - Each call clears the previous timer and starts a new one
func.apply(this, args)preserves boththiscontext and arguments- The function only fires after
waitmilliseconds of silence
const handleSearch = debounce((query) => {
console.log('Searching for:', query);
}, 300);
handleSearch('r'); // timer starts: 300ms
handleSearch('re'); // timer reset: 300ms
handleSearch('rea'); // timer reset: 300ms
handleSearch('reac'); // timer reset: 300ms
handleSearch('react'); // timer reset: 300ms
// 300ms of silence...
// Logs: "Searching for: react" (fires once!)
Level 2: Leading and Trailing Options
The basic debounce fires on the trailing edge -- after the wait. But sometimes you want to fire on the leading edge -- immediately on the first call, then ignore subsequent calls until the silence period.
function debounce(func, wait, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
const leading = options.leading ?? false;
const trailing = options.trailing ?? true;
function invokeFunc() {
func.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
}
function debounced(...args) {
lastArgs = args;
lastThis = this;
const isFirstCall = timeoutId === undefined;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = undefined;
if (trailing && lastArgs) {
invokeFunc();
}
}, wait);
if (leading && isFirstCall) {
invokeFunc();
}
}
return debounced;
}
With leading: true, the function fires immediately on the first call after a silence period. This is useful for things like a submit button -- you want the first click to register instantly, then ignore rapid double-clicks.
const handleClick = debounce(submitForm, 300, { leading: true, trailing: false });
// First click: fires immediately
// Rapid clicks within 300ms: ignored
// After 300ms of no clicks: ready for next first click
Level 3: Production-Grade With maxWait, cancel, and flush
This is the lodash-compatible version. The key addition is maxWait -- a maximum time the function can be delayed. Without it, continuous calls can delay execution indefinitely (the elevator door never closes).
function debounce(func, wait, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let lastCallTime;
let lastInvokeTime = 0;
let maxTimeoutId;
const leading = options.leading ?? false;
const trailing = options.trailing ?? true;
const maxWait = options.maxWait;
const hasMaxWait = maxWait !== undefined;
function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = null;
lastThis = null;
lastInvokeTime = time;
func.apply(thisArg, args);
}
function startTimers(time) {
clearTimeout(timeoutId);
timeoutId = setTimeout(timerExpired, wait);
if (hasMaxWait) {
clearTimeout(maxTimeoutId);
const timeSinceLastInvoke = time - lastInvokeTime;
const maxRemaining = maxWait - timeSinceLastInvoke;
maxTimeoutId = setTimeout(maxTimerExpired, Math.max(0, maxRemaining));
}
}
function timerExpired() {
const time = Date.now();
timeoutId = undefined;
if (trailing && lastArgs) {
invokeFunc(time);
}
clearTimeout(maxTimeoutId);
maxTimeoutId = undefined;
}
function maxTimerExpired() {
const time = Date.now();
maxTimeoutId = undefined;
if (lastArgs) {
invokeFunc(time);
}
clearTimeout(timeoutId);
timeoutId = undefined;
startTimers(time);
}
function debounced(...args) {
const time = Date.now();
lastArgs = args;
lastThis = this;
lastCallTime = time;
const isFirstCall = timeoutId === undefined && maxTimeoutId === undefined;
startTimers(time);
if (leading && isFirstCall) {
invokeFunc(time);
}
}
debounced.cancel = function () {
clearTimeout(timeoutId);
clearTimeout(maxTimeoutId);
timeoutId = undefined;
maxTimeoutId = undefined;
lastArgs = null;
lastThis = null;
lastInvokeTime = 0;
};
debounced.flush = function () {
if (timeoutId === undefined && maxTimeoutId === undefined) return;
const time = Date.now();
clearTimeout(timeoutId);
clearTimeout(maxTimeoutId);
timeoutId = undefined;
maxTimeoutId = undefined;
if (lastArgs) {
invokeFunc(time);
}
};
debounced.pending = function () {
return timeoutId !== undefined || maxTimeoutId !== undefined;
};
return debounced;
}
Here's why each piece matters:
maxWait: Guarantees the function fires within this time window. Prevents indefinite delay. Think of it as "the elevator door closes after 10 seconds no matter what."cancel(): Kills pending invocations. Essential for cleanup when a component unmounts or a user navigates away.flush(): Forces immediate execution of any pending invocation. Useful for "save on blur" -- if the user clicks away, flush the debounced save.pending(): Returns whether there's a pending invocation. Useful for UI indicators.
const save = debounce(saveToServer, 1000, { maxWait: 5000 });
// User types continuously for 8 seconds:
// Without maxWait: save fires once, 1 second after they stop (could be never)
// With maxWait 5000: save fires at 5s, then again 1s after they stop
Building Throttle From Scratch
Level 1: The Minimal Version
function throttle(func, wait) {
let lastCallTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastCallTime >= wait) {
lastCallTime = now;
func.apply(this, args);
}
};
}
This fires on the leading edge only. The first call goes through immediately, then subsequent calls within the wait period are dropped. Simple, but it has a problem: the last call during a burst is lost.
Level 2: Leading and Trailing With Timer
The production version ensures the function fires both at the start of the interval (leading) and at the end (trailing), so you don't lose the final call:
function throttle(func, wait, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let lastCallTime = 0;
const leading = options.leading ?? true;
const trailing = options.trailing ?? true;
function invokeFunc() {
func.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
}
function throttled(...args) {
const now = Date.now();
lastArgs = args;
lastThis = this;
const elapsed = now - lastCallTime;
if (elapsed >= wait) {
clearTimeout(timeoutId);
timeoutId = undefined;
lastCallTime = now;
if (leading) {
invokeFunc();
}
}
if (!timeoutId && trailing) {
timeoutId = setTimeout(() => {
lastCallTime = leading ? Date.now() : 0;
timeoutId = undefined;
invokeFunc();
}, wait - elapsed);
}
}
throttled.cancel = function () {
clearTimeout(timeoutId);
timeoutId = undefined;
lastArgs = null;
lastThis = null;
lastCallTime = 0;
};
return throttled;
}
Why both leading and trailing matter:
- Leading only (
{ trailing: false }): Good for click handlers. Fires on first click, ignores rapid subsequent clicks. But loses the last state update. - Trailing only (
{ leading: false }): Good when you need the most recent value. Fires at the end of each interval with the latest args. - Both (default): Best for scroll/resize. Fires immediately for responsiveness, then again at the end to capture the final position.
const handleScroll = throttle((e) => {
updateProgressBar(window.scrollY);
}, 100);
window.addEventListener('scroll', handleScroll, { passive: true });
Never set both leading: false and trailing: false. The function would never fire -- every call would be silently dropped. Lodash explicitly warns about this in their docs, and your implementation should either throw or treat it as trailing: true.
The Lodash Relationship: Throttle is Debounce With maxWait
Here's an insight that simplifies everything: throttle is just debounce where maxWait equals wait. That's literally how lodash implements it:
function throttle(func, wait, options) {
return debounce(func, wait, {
leading: options?.leading ?? true,
trailing: options?.trailing ?? true,
maxWait: wait,
});
}
Think about why this works. Debounce with maxWait equal to wait means "delay up to wait ms, but never longer than wait ms." That's throttle -- at most one execution per wait period. If you implement debounce correctly with maxWait, you get throttle for free.
requestAnimationFrame-Based Throttle
For visual updates like scroll position, layout recalculation, or animation-driven UI, timestamp-based throttle is wasteful. You don't need to fire every 200ms -- you need to fire once per frame, synchronized with the browser's render cycle:
function throttleRAF(func) {
let frameId;
let lastArgs;
let lastThis;
function throttled(...args) {
lastArgs = args;
lastThis = this;
if (frameId === undefined) {
frameId = requestAnimationFrame(() => {
frameId = undefined;
func.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
});
}
}
throttled.cancel = function () {
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
frameId = undefined;
lastArgs = null;
lastThis = null;
}
};
return throttled;
}
Why rAF over setTimeout:
- Automatically syncs with the display refresh rate (usually 60fps = ~16.6ms)
- Pauses when the tab is backgrounded (saves battery, prevents hidden computation)
- The callback runs before paint, so your DOM updates are fresh for that frame
- No jank from timer drift --
setTimeout(fn, 16)is not reliably 16ms
const handleScroll = throttleRAF(() => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
progressBar.style.transform = `scaleX(${progress})`;
});
window.addEventListener('scroll', handleScroll, { passive: true });
Use rAF throttle for anything that updates the DOM during scroll, resize, or drag. Use time-based throttle for things like API calls, analytics events, or WebSocket messages where frame timing doesn't matter.
Real-World Use Cases
Search Input (Debounce)
const searchInput = document.getElementById('search');
const fetchResults = debounce(async (query) => {
if (!query.trim()) return;
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
renderResults(await results.json());
}, 300);
searchInput.addEventListener('input', (e) => {
fetchResults(e.target.value);
});
300ms is the sweet spot for search. Shorter feels responsive but wastes API calls. Longer and the user notices the delay.
Form Validation (Debounce)
const validateEmail = debounce(async (email) => {
const response = await fetch(`/api/validate-email?email=${encodeURIComponent(email)}`);
const { available } = await response.json();
showValidationState(available ? 'available' : 'taken');
}, 500);
Auto-Save (Debounce With maxWait)
const autoSave = debounce(
(content) => saveToServer(content),
2000,
{ maxWait: 10000 }
);
// Saves 2s after user stops editing, but at least every 10s during continuous editing
Scroll Progress (rAF Throttle)
const updateProgress = throttleRAF(() => {
const scrolled = window.scrollY;
const total = document.body.scrollHeight - window.innerHeight;
progressElement.style.width = `${(scrolled / total) * 100}%`;
});
window.addEventListener('scroll', updateProgress, { passive: true });
Resize Handler (Throttle)
const recalcLayout = throttle(() => {
const width = window.innerWidth;
if (width < 768) switchToMobileLayout();
else switchToDesktopLayout();
}, 200);
window.addEventListener('resize', recalcLayout);
Infinite Scroll (Throttle)
const checkScrollPosition = throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollHeight - scrollTop - clientHeight < 200) {
loadMoreItems();
}
}, 250);
window.addEventListener('scroll', checkScrollPosition, { passive: true });
Cleanup Matters
This is the part candidates forget. In React or any component-based framework, you must cancel pending debounced/throttled calls when the component unmounts:
// In React:
useEffect(() => {
const handleResize = debounce(recalculate, 200);
window.addEventListener('resize', handleResize);
return () => {
handleResize.cancel();
window.removeEventListener('resize', handleResize);
};
}, []);
Without .cancel(), the debounced function fires after the component is gone, calling setState on an unmounted component (or worse, updating DOM elements that no longer exist).
Common Mistakes
| What developers do | What they should do |
|---|---|
| Creating a new debounced function on every render in React If you create debounce(fn, 300) inside a render, each render creates a fresh closure with its own timeoutId. The previous debounce timer is lost and the new one starts from zero. The function fires on every render -- debounce is completely broken. | Create the debounced function once with useRef or useMemo, and clean it up with useEffect |
| Using throttle for search input autocomplete Throttle at 300ms on search input sends 'r', 'rea', 'react' as separate API calls. Debounce at 300ms sends only 'react' once the user stops typing. Fewer requests, better results. | Use debounce. Throttle fires during typing at intervals, sending partial queries. Debounce waits until the user pauses, sending only complete queries. |
| Forgetting to preserve this context and arguments If the debounced function is a method on an object, calling func() loses the this binding. The original arguments also need to be captured in the closure since setTimeout runs later with no arguments. | Use func.apply(this, args) inside the setTimeout callback, not func() |
| Using debounce with wait: 0 thinking it does nothing setTimeout(fn, 0) doesn't mean 'run immediately.' It means 'run after the current call stack clears and pending microtasks drain.' This is actually useful for batching multiple synchronous state updates into a single execution. | debounce(fn, 0) still defers execution to the next event loop tick via setTimeout(fn, 0). It batches synchronous calls and fires once. |
| Not cleaning up debounce/throttle on component unmount A pending setTimeout doesn't care that your component unmounted. It fires and calls your function with references to stale state or removed DOM elements, causing errors or memory leaks. | Always call cancel() in your cleanup function to prevent stale callbacks from firing after unmount |
Interview Tips
When implementing debounce or throttle in an interview, build it up in layers. Don't try to write the full version from the start:
- Start with the basic version (5 lines). Explain
clearTimeout+setTimeoutpattern. - Add
thisandargshandling. Explain whyfunc.apply(this, args)matters. - Add
leading/trailingoptions if asked. Most interviewers are happy with the basic version plus a verbal explanation of how you'd add options. - Mention
cancelandflush. Even if you don't implement them, showing you know they exist demonstrates production awareness. - Mention the lodash relationship. Saying "throttle is debounce with maxWait equal to wait" shows deep understanding.
Edge case: what if wait is 0?
debounce(fn, 0) is not a no-op. setTimeout(fn, 0) defers execution to the next macrotask. This is actually useful: it batches multiple synchronous calls into a single execution at the end of the current call stack.
const batchUpdate = debounce(renderUI, 0);
batchUpdate(); // queued
batchUpdate(); // previous cleared, re-queued
batchUpdate(); // previous cleared, re-queued
// renderUI fires once, after all synchronous code finishesThis is conceptually similar to how React batches state updates.
Key Rules
- 1Debounce delays execution until calls stop. Throttle limits execution to once per interval. Pick the right one for your use case.
- 2Always preserve this context and arguments with func.apply(this, args). Losing context is the most common implementation bug.
- 3Throttle is just debounce with maxWait equal to wait. Master debounce with maxWait and you get throttle for free.
- 4Use requestAnimationFrame-based throttle for DOM updates (scroll, resize, drag). Use time-based throttle for API calls and analytics.
- 5Always implement cancel() for cleanup. In React, call it in useEffect's return function. Stale timers after unmount cause real bugs.
- 6Both leading and trailing options exist. Leading fires on the first call, trailing fires after the wait. The default matters -- debounce defaults to trailing, throttle defaults to both.