How would you implement debounced inputs to improve perceived performance?
Debounce delays an action until N ms after the LAST event (waits for the user to stop). Throttle caps the rate (at most one per N ms). Debounce is right for search-as-you-type, autosave, resize handlers. Throttle is right for scroll, drag, mousemove updates. Implement with lodash.debounce/throttle or hand-rolled with setTimeout + clearTimeout. In React, useDeferredValue or use-debounce hook so the debounced value plays nicely with state.
Input events fire frequently — every keystroke, every scroll pixel, every pointer move. Reacting to each one wastes work and often causes jank. Debounce and throttle are the two main rate-limiting patterns.
Debounce — wait for quiet
Fires once after the input has stopped for N ms.
function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
const onSearch = debounce(q => fetch(`/search?q=${q}`), 300);If the user types fast: many events come in, each cancels the previous timer; only the last (after they pause) fires.
Use for:
- Search-as-you-type input.
- Autosave drafts.
- Resize-triggered relayout.
- "User stopped typing" detection.
Throttle — cap the rate
Fires at most once per N ms, no matter how many events come in.
function throttle(fn, ms) {
let last = 0;
let timer;
return function (...args) {
const now = Date.now();
const wait = ms - (now - last);
if (wait <= 0) {
last = now;
fn.apply(this, args);
} else {
clearTimeout(timer);
timer = setTimeout(() => {
last = Date.now();
fn.apply(this, args);
}, wait);
}
};
}
const onScroll = throttle(() => updateHeader(), 100);Use for:
- Scroll position tracking.
- Mousemove updates.
- Drag handlers.
- API rate limiting from the client.
In React
import { useDeferredValue, useTransition, useState } from 'react';
function Search() {
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query);
const results = useSearchResults(deferred); // hook that fetches/computes
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results items={results} />
</>
);
}useDeferredValue defers updating the dependent computation until the input stops changing — similar effect to debounce but baked into the render scheduler.
For "real" debounce on the value itself:
import { useDebounce } from 'use-debounce';
function Search() {
const [query, setQuery] = useState('');
const [debounced] = useDebounce(query, 300);
useEffect(() => {
if (debounced) fetch(`/search?q=${debounced}`);
}, [debounced]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Combining with cancellation
Debounce reduces the number of requests; AbortController cancels the ones still in flight when the input changes:
useEffect(() => {
if (!debounced) return;
const ctrl = new AbortController();
fetch(`/search?q=${debounced}`, { signal: ctrl.signal })
.then(r => r.json()).then(setResults)
.catch(err => err.name !== 'AbortError' && setError(err));
return () => ctrl.abort();
}, [debounced]);Throttling animations
For scroll/drag/mousemove tied to visual updates, prefer requestAnimationFrame coalescing over time-based throttle:
let scheduled = false;
window.addEventListener('scroll', () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
updateUI();
scheduled = false;
});
});This caps work to one per paint frame (16ms at 60fps) regardless of event rate, and aligns with the browser's render schedule.
leading vs trailing edges
- Trailing (default in most implementations): fire at the end of the wait. Common for debounce.
- Leading: fire immediately on the first event, then suppress until the timer expires. Useful for "fire on click, ignore double-clicks for 500ms."
- Both: rare but useful for "respond instantly + send a final update."
Lodash and most libraries take { leading, trailing, maxWait } options.
Pitfalls
- Debounce inside render —
const debounced = debounce(fn, 300)recreates a new debounced function every render → never fires. Always memoize withuseMemoor useuseCallback. - Stale closures — if the debounced function captures state, it's pinned to the value at creation. Use refs or useDebouncedCallback that updates the ref.
- Forgetting cleanup — pending timers on unmount keep the component alive; cancel on unmount.
- Wrong delay — 100ms for search feels twitchy; 800ms feels sluggish. 250–400ms is a sweet spot for search-as-you-type.
- Throttling animations with setTimeout instead of rAF — janky vs aligned with frames.
- Debouncing analytics — you might lose events on close. Use beacons for end-of-life events.
Mental model
Debounce = "tell me when they stop." Throttle = "tell me at most every N ms." Pick based on whether you care about the final value (debounce) or sampled progress (throttle). For 60fps UI updates, use requestAnimationFrame coalescing instead.
Follow-up questions
- •When would you use leading-edge debounce?
- •What's the difference between throttle and requestAnimationFrame?
- •How does useDeferredValue differ from useDebounce?
- •Why might a debounced callback in React never fire?
Common mistakes
- •Creating the debounced function inside render — never fires consistently.
- •Stale closures — debounced function references old state.
- •Forgetting to cancel on unmount — timers fire on unmounted components.
- •Throttling with setTimeout for animations — use rAF instead.
- •Debouncing analytics with no flush — events lost on page close.
- •Picking wrong delay — too fast feels twitchy, too slow feels broken.
Performance considerations
- •Debounce cuts request rate dramatically (5 keystrokes/sec → 1 request after pause). Throttle bounds CPU (60 scroll events/sec → 10 updates/sec). rAF coalescing caps at the display rate. Combined with abort/cancel, search-as-you-type can go from 'crushing the API' to 'one request after the user pauses.'
Edge cases
- •maxWait in lodash debounce — fires at least every maxWait even if events keep coming.
- •Throttle with leading: false suppresses the first event — usually wrong for UX.
- •Pointermove + rAF combo gives smoothest drag UX (see drag question).
- •Server-sent events (SSE): no need to throttle — server controls rate.
- •iOS Safari may throttle background tab timers — debounce delays effectively get longer.
Real-world examples
- •GitHub omnisearch debounces input.
- •Google Search uses leading debounce for instant feedback + trailing for full results.
- •Scroll-driven nav bar shrink: rAF throttle.
- •Form autosave: debounce 1-2s after last edit.