Debouncing & Throttling
2026-08-06 · 5 min read
Some events fire far faster than you need to respond to them. A user typing in a search box fires a keydown event on every keystroke. A scroll handler fires dozens of times per second. A window resize event fires continuously while the user drags the corner of their browser.
Responding to every event is wasteful at best and harmful at worst — sending a network request on every keystroke, recalculating layout on every scroll pixel, or hammering an API faster than it can respond.
Debouncing and throttling are two techniques for reducing that rate. They solve different problems.
Debouncing
Wait until the user has stopped doing the thing, then fire once.
A debounced function delays execution until a specified quiet period has passed with no new calls. Every new call resets the timer.
keystroke: a ab abc abcd abcde
timer: | | | | |----300ms----|
fires: ↓ "abcde"
Only the last call in a burst executes. All intermediate calls are discarded.
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`).then(/*...*/);
}, 300);
input.addEventListener('input', (e) => handleSearch(e.target.value));
The user can type as fast as they want. A search request only fires 300ms after they stop.
In React
import { useMemo } from 'react';
import { debounce } from 'lodash-es';
function SearchBox() {
const handleSearch = useMemo(
() => debounce((query) => fetchResults(query), 300),
[]
);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
useMemo ensures the debounced function is created once and reused across renders. Without it, a new debounced function (with a fresh timer) is created on every render, defeating the debounce entirely.
On unmount, cancel any pending timer so it doesn't fire after the component is gone:
useEffect(() => {
return () => handleSearch.cancel();
}, [handleSearch]);
When to debounce
- Search-as-you-type (avoid a request per keystroke)
- Form validation (validate after the user finishes a field, not on every character)
- Window resize handlers (recalculate layout once the user stops resizing)
- Autosave (save a draft after typing pauses, not on every character)
Throttling
Fire at most once per interval, no matter how many times the event fires.
A throttled function executes immediately on the first call, then ignores subsequent calls until the interval has elapsed. Unlike debouncing, it guarantees the function fires at a consistent rate during continuous activity.
scroll events: ||||||||||||||||||||||||||||||||||||||||
interval: |---200ms---|---200ms---|---200ms---|
fires: ↓ ↓ ↓ ↓
Every interval produces exactly one execution, even if the underlying event fires hundreds of times.
function throttle(func, wait) {
let shouldWait = false;
return function (...args) {
if (shouldWait) return;
shouldWait = true;
setTimeout(() => { shouldWait = false; }, wait);
func.apply(this, args);
};
}
const handleScroll = throttle(() => {
updateScrollProgress(window.scrollY);
}, 100);
window.addEventListener('scroll', handleScroll);
The scroll progress bar updates at most 10 times per second, regardless of how fast the user scrolls.
When to throttle
- Scroll handlers (progress indicators, sticky header logic, parallax)
- Mouse move handlers (drag, hover effects, cursor tracking)
- Button clicks that trigger expensive operations (prevent double-submit while request is in flight)
- Analytics event logging (record scroll depth at intervals, not on every pixel)
- Game loops or canvas animations (cap update rate to match target FPS)
Debounce vs throttle
| Debounce | Throttle | |
|---|---|---|
| Fires when | Activity stops | At a regular interval |
| Intermediate calls | Discarded | Discarded |
| Fires during activity | No | Yes |
| Best for | Waiting for input to settle | Keeping pace with ongoing activity |
The key question: does it matter what happens during the activity, or only what happens at the end?
- Search box: only the final query matters → debounce
- Scroll progress bar: needs to update while scrolling → throttle
- Window resize: only the final size matters → debounce
- Drag handler: position needs to update during drag → throttle
requestAnimationFrame as a throttle
For visual updates — anything that affects the DOM or canvas — throttle to the display's frame rate using requestAnimationFrame instead of a fixed interval. setTimeout-based throttling at 60fps (16ms) drifts; rAF stays synchronized with the browser's actual paint cycle.
function rafThrottle(fn) {
let rafId = null;
return function (...args) {
if (rafId) return;
rafId = requestAnimationFrame(() => {
fn.apply(this, args);
rafId = null;
});
};
}
const handleMouseMove = rafThrottle((e) => {
updateTooltipPosition(e.clientX, e.clientY);
});
At 60Hz, this fires at most once per ~16ms. At 120Hz, it automatically adjusts to ~8ms. A fixed setTimeout(fn, 16) would not.
Leading vs trailing edge
The implementations above fire on the trailing edge — after the delay or interval. Some use cases need the leading edge — fire immediately on the first call, then enforce the cooldown.
leading throttle: ↓ fire immediately, then ignore for 200ms
trailing throttle: ignore calls, ↓ fire at end of interval
A "submit" button that should respond immediately but prevent double-clicks needs leading debounce or leading throttle. A search box that should wait for the user to pause needs trailing debounce.
Lodash's debounce and throttle accept { leading, trailing } options:
// Fire immediately, don't fire again until 500ms after last call
const handleSubmit = debounce(submitForm, 500, { leading: true, trailing: false });
Using a library
The implementations above cover the core concept but omit edge cases: timer cleanup on unmount, TypeScript types, SSR compatibility, cancellation. In production, use lodash's debounce and throttle (or the lodash-es tree-shakeable version) rather than rolling your own.
For React specifically, use-debounce provides useDebouncedCallback and useThrottle hooks with proper cleanup built in — the same hooks referenced in Optimistic Updates for race condition handling.