
Some browser events are polite and fire once. Others — scroll, resize, mousemove, input — fire in a torrent, sometimes dozens of times per second. Attach an expensive handler to one of those and you've built a jank machine: the main thread thrashes, your INP tanks, and the UI stutters.
The two classic answers are debouncing and throttling. They sound interchangeable and get mixed up constantly, but they answer different questions. Debounce asks "has the user stopped?" Throttle asks "has enough time passed?" Pick the wrong one and the feature feels broken in a subtle way.
Debounce: wait for the pause#
Debouncing delays a function until a certain quiet period has elapsed since the last call. Every new call resets the timer. The function only runs once the storm stops.
The canonical use is a search-as-you-type box. You don't want to fire an API request on every keystroke — you want to wait until the user pauses, then search once for what they actually typed.
function debounce<A extends unknown[]>(
fn: (...args: A) => void,
delay: number,
) {
let timer: ReturnType<typeof setTimeout>;
return (...args: A) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((query: string) => {
fetch(`/api/search?q=${encodeURIComponent(query)}`);
}, 300);
input.addEventListener('input', e => search(e.target.value));
Type "typescript" quickly and search runs exactly once, 300ms after you stop. Type, pause, type again, and it runs twice. That's the behavior you want for anything that should react to a settled value: search inputs, autosave, resize-driven relayout.
Throttle: run at a steady rate#
Throttling guarantees a function runs at most once per interval, no matter how many times it's called. Where debounce waits for silence, throttle fires on a fixed cadence during the activity.
The canonical use is a scroll handler that updates a progress bar or checks scroll position. You want regular updates while scrolling — not one update after the user stops, which would make the bar lurch to its final position.
function throttle<A extends unknown[]>(
fn: (...args: A) => void,
interval: number,
) {
let last = 0;
return (...args: A) => {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn(...args);
}
};
}
const onScroll = throttle(() => {
updateReadingProgress();
}, 100);
window.addEventListener('scroll', onScroll, { passive: true });
Now updateReadingProgress runs at most every 100ms during a scroll — smooth enough to look continuous, cheap enough not to choke the main thread.
The one-line decision#
- The action should happen after the user finishes → debounce. (Search, autosave, validation on a settled input, resize relayout.)
- The action should happen regularly during a continuous stream → throttle. (Scroll position, drag/mousemove tracking, rate-limiting rapid button clicks.)
If you can't decide, ask whether firing during the activity is useful. A progress bar that only updates when you stop scrolling is useless — throttle. A search that fires while you're mid-word is wasteful — debounce.
Doing it in React#
React adds a wrinkle: a new function is created every render, so naively wrapping a handler in debounce inside the component body creates a fresh debounced function each render — and the timer never persists. The fix is to keep the debounced function stable.
A small reusable hook that debounces a value covers most cases cleanly:
import { useEffect, useState } from 'react';
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Then drive the effect off the debounced value:
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return (
<input value={query} onChange={e => setQuery(e.target.value)} />
);
}
The input stays fully controlled and responsive; only the effect waits for the pause. The cleanup function clearing the timer is what makes it correct — without it, an unmount mid-wait would fire a stale update.
A few gotchas#
- Clean up on unmount. A pending timer that fires after a component is gone is a bug (and, in React, a warning). Always clear it.
- Choose the delay deliberately. 200–300ms feels instant-but-settled for typing; too long feels laggy, too short defeats the point. Throttle intervals around 100ms track smoothly for scroll.
- Leading vs trailing edge. Some situations want the function to fire immediately on the first call and respect the interval after. That's a "leading edge" variant — worth knowing the option exists before you need it.
- Reach for a battle-tested version when it gets fiddly. The hand-rolled versions above are perfect for understanding and for simple cases. Once you need leading/trailing edges, cancellation, and max-wait, a well-tested utility earns its keep.
The takeaway#
Debounce waits for quiet; throttle enforces a rhythm. Both exist to protect the main thread from events that fire faster than any handler should run — which, done right, shows up directly in a healthier INP and a UI that stays smooth under the messiest input.
FAQ#
What's the core difference between debounce and throttle?#
Debounce delays a function until a set quiet period passes since the last call, so it runs once after activity stops. Throttle lets a function run at most once per interval during continuous activity. Debounce waits for the pause; throttle enforces a steady rate.
When should I use debounce instead of throttle?#
Use debounce when the action should happen after the user finishes — search-as-you-type, autosave, validating a settled input, or reacting to the final size after a resize. Use throttle when you want regular updates during a continuous stream, like a scroll-position handler or drag tracking.
Why doesn't my debounce work inside a React component?#
Because a new debounced function is created on every render, so its internal timer never persists across renders. Keep the debounced function (or the debounced value) stable — for example with a useDebouncedValue hook driven by useEffect and cleaned up on unmount — rather than wrapping the handler inline in the render body.
Do I need a library like Lodash for this?#
No — the vanilla implementations are a few lines each and fine for typical cases. A library becomes worthwhile when you need the fiddly extras done right: leading and trailing edge control, cancellation, and a maximum wait time.
References: