Most React developers have faced the problem: a search input that fires an API call on every single keystroke, or a scroll handler that runs hundreds of times per second. It wastes bandwidth, clogs the main thread, and creates a terrible user experience. The fix is straightforward — you need to control how often your functions run.
That's where debounce and throttle come in. They're two related but distinct techniques for limiting function execution frequency. Knowing which one to pick saves you from subtle bugs and performance headaches.
👉 Try it in practice: useDebounce vs useThrottle
Debounce: wait until things settle
Debounce delays execution until a certain amount of time has passed without any new calls. Think of an elevator door — it starts closing, but if someone walks in, it reopens and waits again. The door only actually closes once people stop arriving.
In code, debounce is the go-to choice for search inputs. You don't want to query your API on "r", "re", "rea", "reac", "react". You want to wait until the user stops typing and then make one request.
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Every time value changes, the timer resets. Only when the value stops changing for delay milliseconds does the debounced value update. The cleanup function (clearTimeout) is critical — without it, stale timers accumulate and you get unexpected updates.
Other debounce use cases:
- Resize event handlers (wait until the user finishes resizing the browser)
- Autosave in text editors (save after the user pauses typing)
- Form validation triggered on
onChange(validate once, not on every keystroke)
Throttle: guarantee a maximum rate
Throttle works differently. Instead of waiting for things to settle, it guarantees execution at most once within a fixed time window. Think of a revolving door — it lets people through at a controlled, constant rate no matter how many are trying to push in.
Scroll handlers are the classic example. When the user scrolls, the browser fires scroll events at an incredibly high rate — easily 60+ times per second. You rarely need to run logic at that frequency. Throttling to once every 100ms or 200ms keeps the UI smooth while still updating on time.
function useThrottle(value, limit) {
const [throttledValue, setThrottledValue] = useState(value);
const lastRan = useRef(Date.now());
useEffect(() => {
const elapsed = Date.now() - lastRan.current;
if (elapsed >= limit) {
setThrottledValue(value);
lastRan.current = Date.now();
} else {
const timer = setTimeout(() => {
setThrottledValue(value);
lastRan.current = Date.now();
}, limit - elapsed);
return () => clearTimeout(timer);
}
}, [value, limit]);
return throttledValue;
}
The key difference from debounce: throttle tracks the last execution time and allows a new execution only if enough time has elapsed. It doesn't care whether values are still changing — it just enforces a speed limit.
Other throttle use cases:
- Tracking mouse position for tooltips or custom cursors
- Handling
infinite scrollpagination (trigger a fetch at most once every N milliseconds) - Rate-limiting rapid button clicks (prevent double-submits without disabling the button)
Debounce vs Throttle: side by side
The difference becomes clear when you visualize how each behaves with rapid input:
| Technique | Behavior | Analogy | Best for |
|---|---|---|---|
| Debounce | Executes once after activity stops | Elevator door | Search inputs, autosave, validation |
| Throttle | Executes at a fixed maximum rate | Revolving door | Scroll, resize, mouse tracking |
The same input sequence produces different results:
Imagine firing an event 100 times in 1 second. With a debounce of 300ms, the handler runs once (after the last event, provided 300ms passes with no new events). With a throttle of 300ms, the handler runs 3-4 times (roughly once every 300ms during that second).
Common mistakes
Using debounce when you need throttle. A scroll-driven animation debounced at 200ms will look broken — it won't fire at all while the user is scrolling, then it'll jump awkwardly when they stop.
Using throttle when you need debounce. A search input throttled at 300ms sends unnecessary intermediate requests (every 300ms instead of just the final query). The user's network and your API both pay the price.
Not cleaning up timeouts in React. This is the most frequent bug. If your component unmounts while a timeout is pending, setState on an unmounted component triggers React warnings (and in older versions, actual memory leaks). The useEffect cleanup function prevents this.
Forgetting useRef for mutable values. When implementing throttle, you need to track lastRan across renders. Plain variables reset on every render, and useState causes unnecessary re-renders. useRef is the right tool — it persists across renders without triggering updates.
Why custom hooks for this?
You can absolutely import debounce and throttle from lodash or write them as plain utility functions. But wrapping them in hooks gives you something powerful: the delay only affects the debounced/throttled value, not the original source of truth.
Your component keeps an up-to-date value at all times (for immediate UI feedback like showing typed characters in an input), while the debounced/throttled version is used only where needed (for API calls). This separation of concerns is clean, explicit, and prevents the timing logic from leaking across your component.
Going deeper
Most implementations of useDebounce and useThrottle power through the happy path. Real-world usage introduces edge cases worth thinking through:
- What happens when the delay changes dynamically? If a user adjusts a slider controlling debounce delay, does your hook handle it gracefully or does it skip updates?
-
What if
valueis an object? Reference equality means{ query: "react" }will always be "new" — you might want deep comparison or a specific key to watch. -
Should the initial value fire immediately? Some debounce implementations let you pass a
leadingoption to trigger on the first call instead of waiting. Tailwind'sprefers-reduced-motionequivalent is worth considering for accessibility too.
These are exactly the kind of subtleties that turn a 10-line hook into a 50-line one — and the kind of thing that comes up in code reviews.
👉 Try it in practice: useDebounce vs useThrottle
Top comments (0)