Debouncing vs throttling — when to use which
Debounce = wait until the burst stops, then fire once (e.g., search-as-you-type). Throttle = fire at most once per N ms during a burst (e.g., scroll, drag, resize). For visual updates tied to the screen, prefer `requestAnimationFrame` over a fixed throttle interval.
Same family — limit how often a function runs. Different shapes.
Debounce. "Wait until the noise stops, then fire once."
calls: | | | | | |
fires: . .
wait period wait periodUse when you only care about the final state: search-as-you-type, autosave, window resize end, input validation after typing.
Throttle. "Fire at most once per interval, no matter how many calls."
calls: | | | | | | | | | | | |
fires: . . . .Use when you want continuous updates but capped: scroll handlers, mousemove, drag, infinite-scroll triggers, analytics pings.
Minimal implementations.
function debounce<F extends (...a: any[]) => void>(fn: F, ms: number): F {
let t: ReturnType<typeof setTimeout> | undefined;
return ((...a: any[]) => {
clearTimeout(t);
t = setTimeout(() => fn(...a), ms);
}) as F;
}
function throttle<F extends (...a: any[]) => void>(fn: F, ms: number): F {
let last = 0, pending: ReturnType<typeof setTimeout> | undefined, lastArgs: any[];
return ((...a: any[]) => {
const now = Date.now();
lastArgs = a;
const remaining = ms - (now - last);
if (remaining <= 0) {
last = now;
fn(...a);
} else if (!pending) {
pending = setTimeout(() => {
last = Date.now();
pending = undefined;
fn(...lastArgs);
}, remaining);
}
}) as F;
}The throttle variant above is leading + trailing — fire immediately on the first call, then again after the cooldown if more calls came in. Lodash's defaults match this. Leading-only and trailing-only are common variants:
- Leading-only: useful for double-click prevention.
- Trailing-only: useful for "settle then save" — but if that's what you want, debounce is usually clearer.
The third option: requestAnimationFrame. For anything visual (scroll position, parallax, drag preview), don't pick a number of milliseconds — sync to paint:
function rafThrottle<F extends (...a: any[]) => void>(fn: F): F {
let queued = false, lastArgs: any[];
return ((...a: any[]) => {
lastArgs = a;
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
fn(...lastArgs);
});
}) as F;
}This runs at most once per frame (~16ms on 60Hz, ~8ms on 120Hz). Better than throttle(fn, 16) because it aligns with the browser's render cycle and skips work when the tab is hidden.
Listener tips that matter more than the choice.
{ passive: true }onscroll/touchmove— tells the browser you won't callpreventDefault, so it doesn't have to wait for your handler before scrolling. Single biggest scroll-perf fix.- Read DOM in handler, write in rAF. Reading
scrollToptriggers layout if you've written DOM since the last paint. Batch reads, then batch writes inside rAF. - Cleanup. Always
removeEventListeneron unmount. Debounce + unmount can leak the captured closure for the duration of the delay.
Decision rule.
- Need only the final value → debounce.
- Need continuous updates, capped → throttle.
- It's a visual update → rAF throttle.
- It's a render-cost problem, not a network problem →
useDeferredValue/useTransitioninstead.
Follow-up questions
- •Why is rAF preferable to setTimeout-based throttling for animations?
- •Leading vs trailing edge — when do you want each?
- •How does { passive: true } affect scroll performance?
- •When does debouncing inside React cause stale-closure bugs?
Common mistakes
- •Throttling a search input — feels laggy and still over-fetches.
- •Debouncing a scroll handler — visual update lags behind finger by hundreds of ms.
- •Recreating the debounced/throttled fn on every render.
- •Forgetting `{ passive: true }` on scroll/touch listeners.
Performance considerations
- •rAF throttle pauses with the tab — saves battery.
- •Avoid layout reads inside the handler; cache geometry on mount.
- •Use IntersectionObserver / ResizeObserver instead of scroll/resize handlers where possible.
Edge cases
- •Tab in background — setTimeout-based throttle gets clamped to 1s, rAF stops entirely.
- •Cleanup must cancel pending timers and rAFs.
- •Trailing call after unmount can leak state updates.
Real-world examples
- •Search input — debounce. Sticky-header scroll handler — rAF throttle. Drag-to-resize — rAF throttle. Autosave — debounce 1–2s.