I used to slap debounce on everything and call it a day. Then I shipped a throttle on a scroll handler that should have been debounced, and watched the UI stutter for 30 seconds before recovering. That's when I actually learned the difference — not from MDN, but from breaking production.
Here's what I wish someone had told me early: debounce and throttle solve different problems, and picking the wrong one makes your app worse, not better.
The Core Difference in One Sentence
Debounce waits for a pause before firing. Throttle fires at a steady rate, no matter what.
That's it. Everything else follows from this distinction.
Debounce — Wait for the User to Stop
Debounce delays the function call until the user stops triggering it for a specified time. If they keep triggering it, the timer resets.
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}Every new call cancels the previous timer. The function only fires once — after the last call and the delay period.
When debounce is the right choice:
Search input — you don't want to query the API on every keystroke:
const searchInput = document.querySelector('#search');
const handleSearch = debounce(async (query) => {
const results = await fetch(`/api/search?q=${query}`);
renderResults(await results.json());
}, 300);
searchInput.addEventListener('input', (e) => handleSearch(e.target.value));User types "javascript" (10 characters). Without debounce: 10 API calls. With debounce: 1 call, 300ms after they stop typing.
Form validation — validate when the user finishes editing a field:
const validateEmail = debounce((email) => {
if (!email.includes('@')) {
showError('Enter a valid email address');
}
}, 500);
emailInput.addEventListener('input', (e) => validateEmail(e.target.value));Auto-save — save when the user pauses writing:
const autoSave = debounce(async (content) => {
await fetch('/api/draft', {
method: 'PUT',
body: JSON.stringify({ content }),
});
}, 2000);
editor.addEventListener('input', (e) => autoSave(e.target.value));The pattern: wait for the user to finish doing something, then act once.
Throttle — Fire at a Steady Rate
Throttle fires the function at most once every X milliseconds. It guarantees a maximum rate, regardless of how often the event fires.
function throttle(fn, limit) {
let inThrottle = false;
return function (...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}The first call fires immediately. Subsequent calls within the limit window are ignored. Then the window resets.
When throttle is the right choice:
Scroll handler — you need to track scroll position at a reasonable rate:
const onScroll = throttle(() => {
const scrollPercent =
window.scrollY / (document.body.scrollHeight - window.innerHeight);
updateProgressBar(scrollPercent);
}, 100);
window.addEventListener('scroll', onScroll, { passive: true });Scroll events fire 60+ times per second. Without throttle, you're running layout calculations on every frame. With throttle at 100ms, you get ~10 updates per second — smooth enough for a progress bar, light enough for performance.
Mouse move — tracking cursor position for a drawing app or tooltip:
const trackMouse = throttle((x, y) => {
sendToAnalytics({ x, y, timestamp: Date.now() });
}, 200);
canvas.addEventListener('mousemove', (e) => trackMouse(e.clientX, e.clientY));Resize handler — recalculating layout when the window changes size:
const handleResize = throttle(() => {
chart.resize(window.innerWidth, window.innerHeight);
}, 150);
window.addEventListener('resize', handleResize);Rate-limiting API calls — a "load more" button that users might spam:
const loadMore = throttle(async () => {
const data = await fetch(nextPageUrl);
appendToFeed(await data.json());
}, 1000);
loadMoreBtn.addEventListener('click', loadMore);The pattern: keep firing at a controlled rate, never exceed the limit.
The Key Distinction: Leading vs Trailing
This is where most developers get confused.
| Scenario | Debounce | Throttle |
|---|---|---|
| User types 10 chars rapidly | Fires once after they stop | Fires multiple times at steady intervals |
| User scrolls continuously | Fires once after they stop scrolling | Fires regularly while scrolling |
| User clicks button 20x in 2s | Fires once after 2s silence | Fires 2–3 times (depending on limit) |
| Continuous stream of events | Waits for silence, then fires | Paces the output at a fixed rate |
Debounce optimizes for "give me the final value." Throttle optimizes for "give me updates at a reasonable rate."
Leading Edge Variants
Sometimes you want the first call to fire immediately, not after a delay.
Debounce with leading edge — useful for button clicks where the first click should always work:
function debounceLeading(fn, delay, { leading = false } = {}) {
let timer = null;
return function (...args) {
if (timer === null && leading) {
fn.apply(this, args);
}
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args);
}, delay);
};
}Throttle with trailing call — fire the last queued call after the window closes, so you don't miss the final value:
function throttleTrailing(fn, limit) {
let inThrottle = false;
let lastArgs = null;
return function (...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
if (lastArgs) {
fn.apply(this, lastArgs);
lastArgs = null;
}
}, limit);
} else {
lastArgs = args;
}
};
}Use the trailing variant for resize handlers where you need the final dimensions, not just intermediate ones.
The Decision Framework
When you're unsure which to use, ask yourself:
"Do I need the final result, or do I need periodic updates?"
- Final result → debounce (search, validation, auto-save)
- Periodic updates → throttle (scroll, resize, mousemove, analytics)
"Does the intermediate state matter?"
- No (only the last value matters) → debounce
- Yes (I need to show progress/updates along the way) → throttle
"What happens if I miss the last event?"
- Bad (wrong search results, missed auto-save) → debounce
- Fine (scroll position will update soon anyway) → throttle
Common Mistakes I've Made
1. Debouncing scroll handlers
// ❌ Bad — progress bar doesn't update until user stops scrolling
window.addEventListener('scroll', debounce(updateProgress, 100));
// ✅ Good — smooth updates at 10fps
window.addEventListener('scroll', throttle(updateProgress, 100));With debounce, the progress bar freezes while the user scrolls and jumps to the end when they stop. Feels broken.
2. Throttling search inputs
// ❌ Bad — fires while user is still typing, wastes API calls
searchInput.addEventListener('input', throttle(handleSearch, 300));
// ✅ Good — waits for typing to pause
searchInput.addEventListener('input', debounce(handleSearch, 300));With throttle, you get partial queries like "jav", "javascr", "javascript" — 3 API calls instead of 1.
3. Using arrow functions with debounce/throttle
// ❌ Bad — creates a NEW debounced function every render
button.addEventListener('click', (e) => debounce(handleClick, 1000)(e));
// ✅ Good — store the debounced function
const debouncedClick = debounce(handleClick, 1000);
button.addEventListener('click', debouncedClick);Every time the event fires with the wrong pattern, you create a new debounced function with its own timer. The debounce does nothing — every call gets its own isolated timer.
4. Forgetting to clean up
// ❌ Bad — debounce timer fires after component unmounts
useEffect(() => {
const debouncedSave = debounce(saveData, 1000);
return () => {
// timer still fires!
};
}, []);
// ✅ Good — cancel on cleanup
useEffect(() => {
const debouncedSave = debounce(saveData, 1000);
return () => {
debouncedSave.cancel?.(); // if using lodash
// or: clearTimeout(debouncedSave.timer);
};
}, []);React: Custom Hooks for Both
If you're using React, here are production-ready hooks:
function useDebounce(callback, delay) {
const timerRef = useRef(null);
const debouncedFn = useCallback(
(...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callback(...args), delay);
},
[callback, delay]
);
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return debouncedFn;
}
function useThrottle(callback, limit) {
const inThrottle = useRef(false);
const lastArgs = useRef(null);
const throttledFn = useCallback(
(...args) => {
if (!inThrottle.current) {
callback(...args);
inThrottle.current = true;
setTimeout(() => {
inThrottle.current = false;
if (lastArgs.current) {
callback(...lastArgs.current);
lastArgs.current = null;
}
}, limit);
} else {
lastArgs.current = args;
}
},
[callback, limit]
);
return throttledFn;
}Usage:
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedSearch = useDebounce(async (q) => {
const res = await fetch(`/api/search?q=${q}`);
setResults(await res.json());
}, 300);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}Quick Reference
| Use Case | Function | Delay | Why |
|---|---|---|---|
| Search input | debounce | 250–400ms | Wait for typing to pause |
| Form validation | debounce | 400–600ms | Validate after editing |
| Auto-save | debounce | 1000–2000ms | Save after inactivity |
| Scroll handler | throttle | 100–150ms | Smooth updates, no jank |
| Resize handler | throttle | 100–200ms | Layout recalc at reasonable rate |
| Mouse move tracking | throttle | 50–200ms | Depends on precision needed |
| Button spam prevention | throttle | 500–1000ms | First click works, block spam |
| Window resize + final dimensions | throttleTrailing | 150ms | Periodic + catch final value |
Takeaways
- Debounce = wait for silence. Use when you only care about the final value.
- Throttle = cap the rate. Use when you need periodic updates at a controlled speed.
- Search, validation, auto-save → debounce. Scroll, resize, mousemove → throttle.
- Store the debounced/throttled function — don't recreate it on every call.
- Clean up timers in React effects and component unmounts.
- When in doubt, ask: "Do I need the intermediate values?" No = debounce. Yes = throttle.