Every keystroke fires an API call; a single scroll runs your handler hundreds of times a second — and the UI stutters. The problem isn't the event, it's the frequency. scroll, resize, mousemove, and input fire dozens to hundreds of times per second, and running an expensive handler each time clogs the main thread. The fix is to run it fewer times — debounce and throttle. This guide covers the difference, correct vanilla JS implementations, the React pitfalls, and the tie-in to INP.
The problem — events flood
# Search input, user types "hello"
h → API call
he → API call
hel → API call
hell → API call
hello → API call
= 5 calls. Only the last one matters.
# One scroll (a single mouse-wheel tick)
The scroll event fires every ~16ms → 60+ times per second.
A handler that measures layout + setState hogs the main thread → jank.The core intuition — debounce "waits until things go quiet"; throttle "lets one call through per N ms".
Debounce — collapse a burst into one
Coalesce a run of calls into a single execution that fires once the calls have been silent for N ms. "Search when typing stops" is the classic case:
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const search = debounce((q) => {
fetch("/api/search?q=" + encodeURIComponent(q));
}, 300);
input.addEventListener("input", (e) => search(e.target.value));Each call cancels the previous timer with clearTimeout and sets a fresh one. fn only runs after calls go quiet for 300ms. Type "hello" quickly and the API fires once.
Leading edge vs trailing edge
burst: | | | | | (5 calls, gaps < delay)
──────────────────▶ time
trailing (default): one call, delay after the LAST call
|...wait...| → fire (at the end)
leading: fire immediately on the FIRST call, ignore the rest
fire → | | | | (ignored)
both: fire on the first call AND once at the endTrailing is the default — "fire once it's all over". Good for search and autosave. Leading is for "the first click should react immediately" (e.g. a submit-once button). An implementation with options:
function debounce(fn, delay, opts = {}) {
const {leading = false, trailing = true} = opts;
let timer = null;
let calledLeading = false;
function debounced(...args) {
if (leading && !timer) {
fn.apply(this, args);
calledLeading = true;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (trailing && !(leading && calledLeading)) {
fn.apply(this, args);
}
timer = null;
calledLeading = false;
}, delay);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
calledLeading = false;
};
return debounced;
}cancel() discards a pending call — essential for cleanup on unmount (see pitfalls below). Adding flush() ("run the pending call right now") is also common.
Where debounce fits
- Search-as-you-type — query when typing stops
- Autosave — save when edits pause
- Validate-on-stop — validate after input ends
- Resize-end — recompute layout after the window settles
Throttle — at most once per N ms
Run at most once every N ms while a burst continues. Unlike debounce, it fires periodically during the burst. Use it when you can't wait until the end, like tracking scroll position. A timestamp-based implementation:
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
window.addEventListener(
"scroll",
throttle(() => {
updateScrollProgressBar(window.scrollY);
}, 100)
);This simple version is leading-only — it fires on the first call and drops the rest within the interval. The downside: if the last call of a burst lands just before the interval boundary, it's lost. To guarantee the final value, add a trailing timer:
function throttle(fn, interval) {
let last = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
// leading edge — run now
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(this, args);
} else if (!timer) {
// trailing edge — run the last value at the boundary
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}Now it's both leading + trailing. Even when the burst stops, one final call is guaranteed. This is the default behavior of lodash's throttle.
Where throttle fits
- Scroll position tracking — progress bar, sticky header
- Infinite-scroll trigger — detect "near the bottom"
- Drag / mousemove — update coordinates while dragging
- Analytics — sample high-frequency events
requestAnimationFrame throttle — align to frames
Work that repaints the screen (scroll, drag) is better aligned to frames than to a time interval like 100ms. The browser paints every ~16.7ms (60fps), so updating more often than that is invisible. requestAnimationFrame runs once per frame:
function rafThrottle(fn) {
let scheduled = false;
let lastArgs = null;
return function (...args) {
lastArgs = args;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
fn.apply(this, lastArgs);
});
};
}
window.addEventListener(
"scroll",
rafThrottle(() => {
header.style.transform = "translateY(" + (-window.scrollY * 0.3) + "px)";
})
);Benefits — it auto-aligns to the paint cycle, and rAF pauses in background tabs so no wasted work. The rule: use rAF for visual effects, time-based throttle for non-visual logic (like API calls).
Debounce vs throttle — the decision rule
One-line rule — "debounce when only the final value matters; throttle when you need to react periodically while it's still happening".
debounce throttle
──────────────────────────────────────────────────────
when it runs once after quiet once per N ms
during a burst doesn't run runs periodically
the metaphor "search when typing "at most N times
stops" per second"
──────────────────────────────────────────────────────
search input ✓ △ (less suitable)
autosave ✓ △
scroll tracking ✗ (never arrives) ✓
drag coords ✗ ✓
resize-end ✓ △
infinite scroll △ ✓Mix them up and the behavior is wrong. Debounce a scroll progress bar and it won't move while scrolling, then jumps when you stop. Throttle a search and you fire calls for intermediate keystrokes.
React pitfalls — inline debounce is broken
// Broken — a new debounced function on every render
function SearchBox() {
const [q, setQ] = useState("");
// debounce(...) is recreated on every render
// → its internal timer resets each time → zero debounce effect
const onChange = debounce((v) => fetchResults(v), 300);
return <input onChange={(e) => onChange(e.target.value)} />;
}The problem — when setQ changes state the component re-renders, and each render calls debounce(...) again, returning a brand-new function. The new function has its own timer variable, unrelated to the previous pending timer — so nothing is ever debounced.
Fix 1 — useMemo to build the debounced function once:
function SearchBox() {
const [q, setQ] = useState("");
const debouncedFetch = useMemo(
() => debounce((v) => fetchResults(v), 300),
[] // no deps — created once on mount
);
// clear the pending timer on unmount
useEffect(() => () => debouncedFetch.cancel(), [debouncedFetch]);
return (
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
debouncedFetch(e.target.value);
}}
/>
);
}Fix 2 — useRef for a stable instance. React explicitly says it may throw away a useMemo cache, so when you need "never recreate", a ref is safer:
function SearchBox() {
const fetchRef = useRef(null);
if (!fetchRef.current) {
fetchRef.current = debounce((v) => fetchResults(v), 300);
}
useEffect(() => () => fetchRef.current.cancel(), []);
// call fetchRef.current from onChange
}The useCallback pitfall — stale closures
Even if you memoize a handler with useCallback, a debounced function can capture stale props/state and cause a stale-closure bug:
// debouncedSave remembers the count from when it was first created
const save = useMemo(
() => debounce(() => api.save(count), 500),
[] // count is missing → always saves the first count (0)
);The fix — pipe the latest value into a ref and read the ref inside the debounced function. The debounced function never needs to be recreated yet always sees fresh data:
const countRef = useRef(count);
countRef.current = count; // refresh every render
const save = useMemo(
() => debounce(() => api.save(countRef.current), 500),
[]
);React-native alternatives — useDeferredValue / useTransition
When the slowdown is heavy rendering driven by input (filtering a big list, say), React 18+ concurrency features fit more naturally than a timer-based debounce:
function Filter() {
const [query, setQuery] = useState("");
// query updates instantly; the heavy derived value is deferred
const deferred = useDeferredValue(query);
const list = useMemo(() => filterHugeList(deferred), [deferred]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results data={list} />
</>
);
}useDeferredValue keeps the input snappy while deprioritizing the derived render. useTransition marks a state update as explicitly "non-urgent". Unlike debounce, both adapt to the browser's spare time instead of a fixed N ms. But for cutting down network calls, debounce is still the right tool — concurrency features are for render load.
Common pitfalls
1. Not clearing timers on unmount
Leave a pending timer and its callback runs after unmount, calling setState on a gone component → warning / memory leak. Always cancel() (or clearTimeout) in cleanup:
useEffect(() => {
const handler = throttle(onScroll, 100);
window.addEventListener("scroll", handler);
return () => {
window.removeEventListener("scroll", handler);
handler.cancel && handler.cancel(); // clear the pending timer
};
}, []);2. Stale closures
As above, the debounced/throttled function traps old variables. Empty deps = fast but stale; full deps = recreated every time, so debounce breaks. The ref pattern avoids both.
3. Different instance for add vs remove
Wrapping inline, like addEventListener(throttle(fn)), means removeEventListener gets a different instance and fails to detach. Store it in a variable and add/remove the same reference.
4. Picking the delay
- Search debounce — 250–400ms (matches a human typing pause)
- Scroll throttle — 100–200ms; use rAF for visual effects
- Too long feels sluggish; too short barely helps
Why this matters for performance — INP
Left unchecked, high-frequency handlers pile up long tasks (50ms+ of main-thread time), and the user's clicks and keystrokes lag. That's exactly what hurts INP (Interaction to Next Paint), a Core Web Vital. Debouncing or throttling those handlers cuts the number of executions, shrinks long tasks, and improves INP.
One level deeper — mixing layout reads like getBoundingClientRect with DOM writes inside a handler triggers repeated forced reflow. Throttle to cut the count, batch visual updates into rAF, and separate reads from writes. How events queue up and get processed is covered in the event loop guide; measuring INP is covered in the Core Web Vitals guide.
Library or roll your own?
lodash.debounce / lodash.throttle ship leading/trailing/maxWait/cancel/flush in a battle-tested form. If you already use lodash, there's little reason to hand-roll. But if you'd rather not add a dependency — or you want to understand the behavior fully — the 10–20 lines above are enough. In React, a dedicated package like use-debounce handles the ref pitfalls for you.
Summary
- Running high-frequency events (scroll/resize/mousemove/input) as-is clogs the main thread and causes jank.
- Debounce — once after quiet. Search, autosave, validate-on-stop, resize-end.
- Throttle — once per N ms. Scroll tracking, infinite scroll, drag, analytics.
- Decision rule — debounce when only the final value matters; throttle when you need periodic reaction during the burst.
- Align visual updates with a
requestAnimationFramethrottle (~60fps). - React — inline
debounce(fn)is recreated every render and breaks. Pin the instance withuseMemo/useRef, and pass fresh values via a ref to avoid stale closures. - Use
useDeferredValue/useTransitionfor render load; debounce for network calls. - Clear timers with
cancel()in cleanup — otherwise leaks / setState-after-unmount. Fewer calls = fewer long tasks = better INP.