Debouncing and Throttling in React the Right Way

Photo by Ansgar Koreng via Wikimedia Commons (CC BY-SA 4.0)
Debouncing waits for a pause: it only runs the function after events stop for the full delay, which is ideal for search inputs and autosave. Throttling runs the function at most once per interval no matter how many events arrive, which fits scroll and resize handlers. In short, debounce cares about the last value after activity settles, while throttle enforces a steady sampling rate during activity.
Almost always because the effect starts a setTimeout but never returns a cleanup that clears it. Each render then schedules a brand-new timer without cancelling the previous one, so ten keystrokes fire ten calls. Returning a cleanup function that calls clearTimeout cancels the pending timer before the next run and on unmount, which fixes both the duplicate calls and the memory leak.
Return a cleanup function from the useEffect that owns the timer, and React runs it automatically on unmount as well as before each re-run. For a debounced callback stored in a ref, add a separate effect with an empty dependency array whose cleanup calls clearTimeout on the ref. This prevents the timer from firing setState on an unmounted component.
Debounce a value when you derive something from it, like a search query that feeds a fetch — useDebounce returns the settled value and keeps the component simple. Debounce a callback when you are triggering an action such as a save or an analytics event. The callback version needs a ref to the latest function so it does not fire a stale closure.
useEffectEvent, stable since React 19.2, solves the stale-closure half of the problem by giving you a function that always reads the latest props and state without joining the dependency array. You still need the timer and its cleanup for the actual debouncing. Many teams also prefer a maintained library like use-debounce or Lodash for tricky options such as maxWait, leading, and trailing edges.

Photo by Ansgar Koreng via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Debouncing delays a function until input stops; throttling caps it to at most once per interval. In React, put the timer inside useEffect and always return a cleanup that clears it. That cleanup cancels the pending timer before the next run and on unmount, which is what stops leaked timers, duplicate API calls, and stale closures.
Every React app eventually grows an input that fires too often. A search box that hits your API on every keystroke, a scroll handler that recomputes layout sixty times a second, an autosave that writes on every character. Debouncing and throttling are the two tools for taming that, and both are trivial to get subtly wrong in React because the timer lives across renders while your component is constantly recreating closures around it.
I run a self-hosted stack where a debounced search endpoint sits in front of a PostgreSQL full-text query. The first version leaked timers and hammered the database on every keypress. This is the guide I wish I had then: what the two techniques actually do, why the naive useEffect version leaks, and a small set of reusable hooks that clean up correctly.
Debouncing waits for a pause. It restarts a timer on every event and only runs the function once the events stop for the full delay. Throttling instead lets the function run immediately, then blocks it until a fixed interval has passed, giving you a steady cadence no matter how fast events arrive. The difference matters: debounce is about the last value after activity settles, throttle is about a predictable sampling rate during activity.
| Aspect | Debounce | Throttle |
|---|---|---|
| Fires when | Activity stops for the full delay | At most once per interval, while active |
| Best for | Search inputs, form validation, autosave | Scroll, resize, mousemove, drag |
| Worst case | If input never pauses, it never fires | Fires on a fixed cadence regardless |
| User feel | Waits for you to finish | Steady, continuous updates |
| Typical delay | 300 to 500 ms | 100 to 250 ms |
The tempting first attempt is to schedule a setTimeout inside a useEffect keyed on the input value, and stop there. It looks like it works in the demo, then falls apart under real typing. Because there is no cleanup, every keystroke re-runs the effect and starts a brand-new timer without cancelling the previous one. Type ten characters and you schedule ten API calls that all fire.
// The naive version — a new timer on every render, no cleanup
function SearchBox() {
const [query, setQuery] = useState("");
useEffect(() => {
// BUG: this timer is never cleared. Type 10 characters and you
// schedule 10 requests, and every one of them fires.
setTimeout(() => {
fetch(`/api/search?q=${query}`);
}, 500);
}, [query]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}If the component unmounts while a timer is pending, that timer still fires and may call setState on an unmounted component or reference props that no longer exist. Uncleared timers are the classic React memory-leak source: zombie timers that hold their closure alive and do work nobody is waiting for.
The cleanest primitive debounces a value, not a callback. You return the cleanup function from useEffect, and React runs it before the next effect and again on unmount. Each keystroke cancels the previous timer, so only the value that survives the full quiet period ever lands in state. This is the single most important line in the hook, and the one the naive version omits.
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
// Cleanup runs before the next effect AND on unmount:
// it cancels the pending timer so only the last value survives.
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}Now the consumer stays simple. Track the raw input in state, feed it through useDebounce, and run your effect on the debounced value. Pair the fetch with an AbortController so an in-flight request is cancelled if the debounced value changes again before it resolves, which prevents out-of-order responses overwriting fresh results.
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
const controller = new AbortController();
fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch(() => {}); // ignore aborts
return () => controller.abort();
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Sometimes you need to debounce an action, not derive a value: a save button, an analytics event, a resize-driven recompute. The trap here is the stale closure. If you capture the callback once, the debounced function keeps calling an old version that sees old props and state. The fix is to store the latest callback in a ref and update it on every render, so the timer always calls the current function while the debounced identity stays stable.
import { useCallback, useEffect, useRef } from "react";
export function useDebouncedCallback<A extends unknown[]>(
callback: (...args: A) => void,
delayMs: number,
) {
const timer = useRef<ReturnType<typeof setTimeout>>();
// Keep the latest callback in a ref so the debounced function
// always sees fresh props/state — no stale closure.
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
const debounced = useCallback(
(...args: A) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => latest.current(...args), delayMs);
},
[delayMs],
);
// Cancel any pending call when the component unmounts.
useEffect(() => () => clearTimeout(timer.current), []);
return debounced;
}The ref-plus-effect pattern is exactly the problem React 19.2 solved with useEffectEvent: a stable function identity that always reads the latest values without going into the dependency array. If you are on 19.2 or later, you can lean on it instead of hand-rolling the latest-callback ref for the non-reactive part of the logic.
For high-frequency events, debounce is the wrong tool: a scroll handler that only fires after you stop scrolling feels broken. You want throttle, running at most once per interval. A timestamp-based throttle is the simplest correct form. And crucially, whatever you attach with addEventListener must be removed in the same effect cleanup, or you leak a listener on every mount.
import { useCallback, useEffect, useRef } from "react";
export function useThrottledCallback<A extends unknown[]>(
callback: (...args: A) => void,
intervalMs: number,
) {
const lastRun = useRef(0);
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
return useCallback(
(...args: A) => {
const now = Date.now();
if (now - lastRun.current >= intervalMs) {
lastRun.current = now;
latest.current(...args);
}
},
[intervalMs],
);
}
// Usage: throttle a scroll handler to at most once per 200ms
function Header() {
const onScroll = useThrottledCallback(() => {
// read scrollY, toggle a shadow, etc.
}, 200);
useEffect(() => {
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return null;
}Whether you debounce a value, debounce a callback, or throttle an event, the same discipline applies. Every timer, listener, or subscription you start must be torn down in a cleanup function. React fires that cleanup before re-running the effect and one final time when the component unmounts, which covers both the re-render case and the teardown case.
You do not always have to write these yourself. The use-debounce package on npm gives you useDebounce and useDebouncedCallback with cancel, flush, and maxWait already handled, and Lodash debounce and throttle remain the reference implementations for the leading and trailing edge options most hand-rolled versions get wrong.
Reach for the custom hooks when you want zero dependencies and full control, and for the library when you need the tricky options. Either way, the mental model is the same: the timer outlives a render, so a render must never leave one behind.