Overview
Debounce and throttle are rate-limiting techniques used to control how often expensive work runs in response to frequent events. In React applications, they are commonly used for search inputs, filtering, autosave, resize handlers, scroll handlers, drag interactions, validation checks, and expensive derived UI updates.
Debounce waits until activity pauses before running work. It is ideal when only the final value matters, such as search after the user stops typing or autosave after editing settles. Throttle runs work at most once per time window. It is ideal when updates should happen periodically during continuous activity, such as scroll position tracking or resize previews.
This topic matters because React apps often handle high-frequency input. Without rate limiting, a component can trigger too many API calls, expensive filters, renders, validations, or storage writes. With the wrong rate-limiting strategy, the UI can feel laggy, stale, or unreliable.
For interviews, this topic is important because it tests practical frontend performance judgment. Strong candidates can explain debounce versus throttle, cancellation, cleanup, stale closures, request aborting, autosave failure states, and the difference between delaying side effects and deferring rendering.
Core Concepts
High-Frequency Events
Some UI events fire often:
- Typing in a search box.
- Filtering a table.
- Resizing the window.
- Scrolling.
- Dragging.
- Moving a pointer.
- Editing a large form.
- Updating a slider.
If every event triggers expensive work, the app can become slow.
Expensive work includes:
- Network requests.
- Filtering thousands of rows.
- Recalculating charts.
- Writing drafts to storage.
- Running validation against an API.
- Updating large React subtrees.
- Sending analytics events.
Debounce and throttle are tools for controlling that work.
Debounce
Debounce delays a function until no new calls happen for a specified wait time.
Use debounce when the final value matters more than intermediate values.
Common uses:
- Search after typing pauses.
- Autosave after editing pauses.
- Validate username availability after typing pauses.
- Recalculate expensive filters after the user stops changing inputs.
- Save layout after resize stops.
Simple debounce helper:
function debounce<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
waitMs: number,
) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: TArgs) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
callback(...args);
}, waitMs);
};
}
Each call resets the timer. The callback runs only after the input settles.
Throttle
Throttle limits a function so it runs at most once per time window.
Use throttle when the user needs periodic updates during continuous activity.
Common uses:
- Scroll position tracking.
- Resize updates during resizing.
- Drag preview updates.
- Pointer move calculations.
- Rate-limited analytics.
- Progress-like UI that should update steadily but not constantly.
Simple throttle helper:
function throttle<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
waitMs: number,
) {
let lastRun = 0;
return (...args: TArgs) => {
const now = Date.now();
if (now - lastRun >= waitMs) {
lastRun = now;
callback(...args);
}
};
}
This basic version runs on the leading edge only. Production throttles often support trailing calls too so the final value is not lost.
Debounce vs Throttle
The key difference:
- Debounce waits for silence.
- Throttle allows periodic execution.
Use debounce for:
- Search request after typing stops.
- Autosave draft after user pauses.
- Username availability check.
- Expensive table filtering where intermediate values do not matter.
Use throttle for:
- Scroll position updates.
- Resize preview during resizing.
- Drag or pointer movement.
- Rate-limited progress-like updates.
Interview shortcut: debounce answers "run after the user pauses"; throttle answers "run at a controlled frequency while the user continues."
Leading and Trailing Behavior
Rate-limited functions can run on the leading edge, trailing edge, or both.
Leading edge:
- Run immediately on the first call.
- Useful for instant feedback.
- Can ignore the final value unless trailing is also enabled.
Trailing edge:
- Run after the wait period with the latest arguments.
- Useful for search and autosave.
- Feels less immediate but preserves final value.
Both:
- Run immediately, then also run once more with the latest value if calls continued.
- Useful when the UI needs quick response and final correctness.
For search, trailing debounce is common. For scroll, leading plus trailing throttle is often useful.
Debounced Search
Search is the classic debounce example.
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timeoutId = window.setTimeout(async () => {
const nextResults = await searchProducts(query);
setResults(nextResults);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [query]);
return (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search products"
/>
<SearchResults results={results} />
</>
);
}
This avoids a request on every keystroke. The cleanup cancels the previous scheduled search when query changes again.
Search Race Conditions
Debounce reduces request volume, but it does not eliminate race conditions.
Example:
- User searches
react. - Request starts.
- User changes query to
react hook form. - Second request starts.
- First request finishes last and overwrites results.
Use AbortController or stale-response guards.
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(async () => {
try {
const nextResults = await searchProducts(query, controller.signal);
setResults(nextResults);
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
setError("Search failed");
}
}
}, 300);
return () => {
window.clearTimeout(timeoutId);
controller.abort();
};
}, [query]);
Debounce schedules work. Abort or stale guards control in-flight work.
Debounced Filtering
Client-side filtering can be expensive when the list is large.
function ProductFilter({ products }: { products: Product[] }) {
const [input, setInput] = useState("");
const [filter, setFilter] = useState("");
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setFilter(input);
}, 200);
return () => window.clearTimeout(timeoutId);
}, [input]);
const visibleProducts = useMemo(
() => filterProducts(products, filter),
[products, filter],
);
return (
<>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<ProductTable products={visibleProducts} />
</>
);
}
This keeps typing responsive because the input state updates immediately, while expensive filtering waits until the user pauses.
For very large lists, also consider virtualization, indexing, server-side search, or React rendering tools such as useDeferredValue.
Debounced Autosave
Autosave should usually be debounced. Saving every keystroke can overload the server and create race conditions.
function DraftEditor({ draftId }: { draftId: string }) {
const [body, setBody] = useState("");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
useEffect(() => {
if (!body.trim()) {
return;
}
const timeoutId = window.setTimeout(async () => {
setStatus("saving");
try {
await saveDraft(draftId, body);
setStatus("saved");
} catch {
setStatus("error");
}
}, 1000);
return () => window.clearTimeout(timeoutId);
}, [draftId, body]);
return (
<>
<textarea value={body} onChange={(event) => setBody(event.target.value)} />
<p>{status === "saving" ? "Saving..." : status}</p>
</>
);
}
Production autosave also needs conflict handling, retry behavior, offline handling, and clear status.
Autosave Race Conditions
Autosave has ordering problems.
Example:
- Save A starts with older content.
- Save B starts with newer content.
- Save B completes first.
- Save A completes last and overwrites newer content.
Mitigation strategies:
- Send document version or ETag.
- Use server-side optimistic concurrency.
- Abort old saves when possible.
- Queue saves and run one at a time.
- Ignore stale completions on the client.
- Save patches instead of full documents when appropriate.
Client debounce is not enough to guarantee data correctness.
Throttled Scroll or Resize
Throttle is better than debounce when the UI needs updates during continuous movement.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
let lastRun = 0;
function handleResize() {
const now = Date.now();
if (now - lastRun >= 100) {
lastRun = now;
setWidth(window.innerWidth);
}
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
This avoids rerendering on every resize event.
requestAnimationFrame
For visual updates tied to painting, requestAnimationFrame can be better than a fixed timer. It asks the browser to run a callback before the next repaint.
Example:
function useScrollY() {
const [scrollY, setScrollY] = useState(window.scrollY);
useEffect(() => {
let frameId: number | null = null;
function handleScroll() {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(() => {
setScrollY(window.scrollY);
frameId = null;
});
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
};
}, []);
return scrollY;
}
This is a frame-based throttle. It is useful for scroll-linked visual state, but it is not a replacement for debouncing network requests.
Stable Debounced Functions in React
Debounced functions need stable identity. If a debounced function is recreated on every render, it loses its timer.
Custom hook example:
function useDebouncedCallback<TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
waitMs: number,
) {
const callbackRef = useRef(callback);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback((...args: TArgs) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, waitMs);
}, [waitMs]);
}
This keeps the debounced wrapper stable while still calling the latest callback.
Cleanup
Timers and scheduled work must be cleaned up.
Cleanup prevents:
- Running work after unmount.
- Updating state on an irrelevant component.
- Autosaving stale values.
- Firing duplicate requests.
- Leaking event listeners.
- Leaving trailing debounced work after navigation.
Use cleanup in useEffect:
useEffect(() => {
const timeoutId = setTimeout(runSearch, 300);
return () => clearTimeout(timeoutId);
}, [runSearch]);
If using a library debounce, call .cancel() during cleanup when appropriate.
Stale Closures
Debounced and throttled callbacks can capture old values.
Problem:
const debouncedSave = useMemo(
() => debounce(() => saveDraft(body), 1000),
[],
);
This captures the initial body value.
Better:
const debouncedSave = useDebouncedCallback((nextBody: string) => {
saveDraft(nextBody);
}, 1000);
function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
const nextBody = event.target.value;
setBody(nextBody);
debouncedSave(nextBody);
}
Pass the latest value as an argument or keep the latest callback in a ref.
Debounce vs useDeferredValue
Debounce delays work. useDeferredValue defers rendering work.
Use debounce when:
- You want fewer network requests.
- You want fewer autosaves.
- You want fewer expensive calculations.
- You want work to happen only after input settles.
Use useDeferredValue when:
- The input should update immediately.
- A slow child tree can lag behind.
- You want React to prioritize typing over rendering results.
Important: useDeferredValue does not reduce network requests by itself. It changes rendering priority, not the number of side effects you start.
Debounce vs useTransition
useTransition marks state updates as non-blocking. It is useful when a state update causes expensive rendering and should not block urgent input.
It is not a timer and it is not a rate limiter.
Use transition for:
- Switching tabs with expensive content.
- Updating a slow result panel while input remains responsive.
- Showing pending visual state for non-urgent UI updates.
Use debounce or throttle for:
- Limiting API calls.
- Limiting storage writes.
- Limiting event handler frequency.
These tools can be combined, but they solve different problems.
Search UX
Good debounced search UX includes:
- Input updates immediately.
- Search runs after a short pause.
- The UI shows loading state after the request starts.
- Old request is canceled or ignored.
- Empty query clears results or shows defaults.
- Errors are recoverable.
- Results show which query they represent if stale results stay visible.
Avoid:
- Waiting to update the input value itself.
- Sending a request for every keystroke.
- Letting old results overwrite new results.
- Showing a spinner forever if a request is aborted.
Filtering UX
For client-side filtering:
- Keep the input immediate.
- Debounce the expensive filter value.
- Memoize derived filtered results.
- Consider
useDeferredValuefor slow result rendering. - Virtualize large lists.
- Move very large filtering to the server or a worker.
Filtering 50 rows does not need heroic optimization. Filtering 50,000 rows while rendering a table probably does.
Autosave UX
Autosave needs trust-building UI.
Good autosave UX:
- Shows unsaved changes.
- Shows saving state.
- Shows saved state and time.
- Shows failure state with retry.
- Preserves edits offline or during failures.
- Avoids overwriting newer server data.
- Saves on navigation or warns before leaving when needed.
Debounce controls frequency, but product correctness also needs versioning and error handling.
Common Mistakes
Common mistakes include:
- Debouncing the input value itself so typing feels delayed.
- Recreating a debounced function every render.
- Forgetting cleanup on unmount.
- Not canceling or ignoring stale search requests.
- Using debounce for scroll when throttle or
requestAnimationFrameis better. - Using throttle for search and missing the final intended value.
- Autosaving without handling out-of-order responses.
- Hiding autosave failures.
- Using
useDeferredValueexpecting it to reduce network calls. - Rate-limiting cheap work while ignoring the actually expensive render.
Best Practices
Best practices include:
- Debounce search, async validation, autosave, and expensive final-value work.
- Throttle scroll, resize, drag, pointer move, and periodic visual updates.
- Keep controlled inputs immediate.
- Cancel timers in cleanup.
- Abort or ignore stale requests.
- Use stable debounced or throttled callbacks.
- Pass latest values as arguments to avoid stale closures.
- Show loading, saving, saved, and error states.
- Use
requestAnimationFramefor frame-aligned visual updates. - Use
useDeferredValueoruseTransitionfor rendering priority, not request limiting. - Measure before optimizing simple interactions.