Debouncing is one of those patterns you implement once and then never think about again — until you see a developer fire an API call on every keystroke and wonder why the server is melting. The concept is simple: delay execution until a burst of calls quiets down. The implementation is ten lines. Getting it right in React needs a bit more care.
What changed in 2026
use-debounce is the standard React hook — it handles the useRef stabilization for you and has TypeScript types built in.
- Native
scheduler API is maturing — the browser Scheduler API offers priority-based scheduling, but for input debouncing the classic setTimeout approach is still the right tool.
- React 19
useDeferredValue handles the UI update side of debouncing for React-managed state, but does not prevent API calls — you still need a debounce for network requests.
- Server Components reduce the need for client-side debounce — if the search is a server action, you debounce the submit, not the render.
The plain JavaScript implementation
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Usage
const onSearch = debounce((query) => {
console.log("searching for", query);
}, 300);
document.querySelector("#search").addEventListener("input", (e) => {
onSearch(e.target.value);
});
Every call resets the timer. The wrapped function only executes once there is a 300 ms gap in calls.
TypeScript version
function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
React — the wrong way
// Bad: creates a new debounced function on every render
function SearchInput() {
const handleChange = debounce((e) => {
fetchResults(e.target.value);
}, 300);
return <input onChange={handleChange} />;
}
Every render discards the previous debounced function and creates a fresh one — the timer never accumulates the silence it needs.
React — the right way with useRef
import { useRef, useCallback } from "react";
function SearchInput() {
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
fetchResults(e.target.value);
}, 300);
}, []); // stable reference — no deps
return <input onChange={handleChange} />;
}
React — the easiest way with use-debounce
npm install use-debounce
import { useDebouncedCallback } from "use-debounce";
function SearchInput() {
const handleChange = useDebouncedCallback((value: string) => {
fetchResults(value);
}, 300);
return <input onChange={(e) => handleChange(e.target.value)} />;
}
useDebouncedCallback returns a stable function — no useRef boilerplate needed.
Debounce a React state value (for UI)
import { useState } from "react";
import { useDebounce } from "use-debounce";
function SearchPage() {
const [input, setInput] = useState("");
const [query] = useDebounce(input, 300);
// query updates 300ms after the user stops typing
// use query to drive your data fetching
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<Results query={query} />
</>
);
}
This pattern keeps the input responsive (updates on every keystroke) while deferring the expensive downstream effect.
Debounce vs throttle
| Pattern |
Fires when |
Use case |
| Debounce |
After N ms of silence |
Search, auto-save, resize-end |
| Throttle |
At most once per N ms |
Scroll handlers, analytics events |
| Leading debounce |
Immediately, then silent for N ms |
Button click de-duplication |
| useDeferredValue |
React decides (concurrent) |
Keeping input fast, deferring list render |
How to pick the delay
| Use case |
Typical delay |
| Search / autocomplete |
250–350 ms |
| Form auto-save |
800–1 500 ms |
| Window resize handler |
100–200 ms |
| Button double-click guard |
300–500 ms |
| Scroll position updates |
50–100 ms (throttle is better here) |
Common mistakes
Debouncing inside the render function. Explained above — the function is recreated and the timer is lost.
Not cleaning up the timer on unmount. If the component unmounts while the timer is pending, the callback fires on an unmounted component. Add a cleanup in useEffect if you build this manually.
Using debounce when you mean throttle. A scroll handler debounced at 200 ms fires nothing while the user is scrolling, then fires once they stop. Usually you want periodic updates while scrolling — that is throttle.
Setting delay too high on search. A 1 000 ms debounce on a search input feels broken. 250–350 ms is the sweet spot for typing speed.
What to skip
- Lodash just for debounce —
lodash.debounce standalone is 6 kB minzipped. Write the 10-line version or use use-debounce (< 1 kB).
- Debouncing server actions in Next.js App Router — Next.js already batches transitions; debounce the user input, not the server action call.
- RxJS
debounceTime for non-reactive codebases — huge dependency for one operator.
FAQ
Can I cancel a pending debounced call?
Yes. The use-debounce useDebouncedCallback returns a function with a .cancel() method. Call it in a useEffect cleanup: return () => handleChange.cancel().
How is debounce different from useDeferredValue?
useDeferredValue is a React scheduling hint — it keeps the current value for the urgent render and schedules a lower-priority render with the new value. It does not delay network calls. Use both: useDeferredValue for UI smoothness, debounce for network requests.
Should I debounce or use AbortController?
Both together. Debounce reduces the number of requests fired; AbortController cancels in-flight requests that are superseded.
Does debounce work with async functions?
Yes, but the timer callback just fires the async function — it does not await it. The returned promise is discarded. Use AbortController inside the async function to cancel previous in-flight calls.
Where to go next