What you'll learn
Quick Answer
Debounce delays the call until the events stop for a set period, so rapid typing produces one request after the user pauses. Throttle lets the function run at most once per interval no matter how many events arrive, so a scroll handler runs on a steady rhythm. Use debounce for search inputs and autosave, throttle for scroll, resize and drag. In React, create the debounced function once and clear its timer on unmount.
The difference in one paragraph
Both take a function that would otherwise fire far too often and reduce how many times it actually runs. They differ in which calls survive.
Debounce restarts a timer on every event and only runs the function once the events stop for the chosen delay. If a student types data structures into a search box at normal speed, a 300 ms debounce fires once, after the last keystroke. If they never pause, it never fires.
Throttle runs the function immediately, then ignores further calls until the interval has elapsed. With a 200 ms throttle on a scroll handler, the handler runs about five times a second for as long as scrolling continues, regardless of the hundreds of scroll events the browser dispatched.
A concrete way to remember it: debounce answers are they finished?, throttle answers has enough time passed?. Debounce can starve, meaning a continuous stream of events produces zero calls until the stream stops. Throttle never starves, but it also never gives you a call that lines up exactly with the last event unless you add a trailing call.
That last sentence is where most throttle bugs live. Naive throttle implementations only fire on the leading edge, so the final scroll position, the final mouse position on a drag, or the final resize dimensions are simply dropped. Your infinite scroll then fails to load the next page because the one event that would have crossed the threshold happened inside the cool-down window. A production throttle keeps the last arguments and fires them when the window closes.
The cost of the wrong choice is not just performance. Debouncing a scroll handler that decides whether a sticky header should be visible makes the header lag noticeably. Throttling a search box sends a request mid-word and shows results for dat while the user is typing data.
Writing debounce yourself
The whole idea is one timer variable held in a closure. Every call cancels the pending timer and schedules a new one.
function debounce(fn, wait) {
let timer;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = undefined;
};
return debounced;
}
const search = debounce((q) => console.log('searching', q), 300);
search('d'); search('da'); search('dat'); search('data');
// after 300 ms of quiet: 'searching data'Two details that matter in a code review. First, debounced is a normal function, not an arrow, so this inside it is the call-site receiver. The arrow passed to setTimeout then closes over that this, which is what makes obj.method = debounce(obj.method, 300) behave. Second, exposing cancel is not optional in a component-based app. Without it you cannot stop a queued call when the component unmounts, and the callback runs against state that no longer exists.
Sometimes you want the call on the leading edge instead, for example to stop a double-tapped submit button from creating two orders:
function debounceLeading(fn, wait) {
let timer = null;
return function (...args) {
if (timer === null) fn.apply(this, args);
clearTimeout(timer);
timer = setTimeout(() => { timer = null; }, wait);
};
}Pick the delay by what the user perceives. Around 250 to 400 ms for typing feels responsive while still collapsing a burst of keystrokes; much below that and you are back to a request per character, much above and the results feel sluggish. For autosaving a long form, one or two seconds is fine, but pair it with a save on blur so a user who types and immediately closes the tab does not lose the last edit.
Writing throttle, including the trailing call
The simplest throttle compares timestamps. It is easy to read and it is what most interviewers expect first.
function throttle(fn, wait) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}Correct, and incomplete. It drops every call that lands inside the cool-down, including the final one. Add a trailing call so the last event is never lost:
function throttle(fn, wait) {
let last = 0;
let timer = null;
let queued = null;
return function throttled(...args) {
const now = Date.now();
const remaining = wait - (now - last);
queued = { ctx: this, args };
if (remaining <= 0) {
if (timer) { clearTimeout(timer); timer = null; }
last = now;
queued = null;
fn.apply(this, args);
} else if (timer === null) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
const q = queued;
queued = null;
if (q) fn.apply(q.ctx, q.args);
}, remaining);
}
};
}Before you ship a hand-written throttle for anything visual, check whether the platform already solves it. If the handler reads layout and then writes styles, requestAnimationFrame is a better gate than a fixed interval because it aligns with the browser's paint cycle:
const header = document.querySelector('.site-header');
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
header.classList.toggle('shrunk', window.scrollY > 80);
ticking = false;
});
});And for the two most common reasons people throttle scroll at all, there are dedicated APIs that do the work off the main thread. IntersectionObserver tells you when an element enters the viewport, which covers lazy loading and infinite scroll. ResizeObserver reports element size changes without a resize listener. Reach for those first, then throttle whatever is left.
One more platform detail: add { passive: true } to touchstart, touchmove and wheel listeners you never call preventDefault() in, so the browser does not have to wait to find out whether you will cancel the gesture. Modern browsers already treat touch listeners registered on window, document and document.body as passive by default. The scroll event itself is not cancelable, so the flag changes nothing there; what keeps scrolling smooth is a handler that stays cheap.
The React bug: a new debounced function every render
This is the version that gets written in a hurry and looks right.
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// Broken: a brand new debounced function on every render
const onChange = debounce((value) => {
fetch(`/api/search?q=${value}`).then(r => r.json()).then(setResults);
}, 300);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
onChange(e.target.value);
}}
/>
);
}Each keystroke calls setQuery, which re-renders, and every render evaluates debounce(...) again and produces a fresh closure with its own timer variable. The new function knows nothing about the timer pending inside the previous one, so nothing gets cancelled and you get a request per keystroke. The debounce is decorative.
The re-render is the essential ingredient, and that is why this bug is confusing to reproduce. In a component that does not re-render while the user types, the same debounced function survives across keystrokes and appears to work perfectly. Make the input controlled, or start rendering the results, and the same code silently stops debouncing.
The fix that needs no library is to move the timing into an effect keyed on the value. The cleanup function cancels both the timer and the in-flight request:
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) { setResults([]); return; }
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
setResults(await res.json());
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}The AbortController is doing separate and equally important work. Debouncing reduces how many requests you send, it does not order the responses you get back. Without aborting, a slow response for data can land after a fast response for data structures and overwrite the correct results. That is the stale response race, and it looks to users like the search box randomly showing the wrong list.
If you prefer a debounced callback object, create it once and cancel it on unmount:
const save = useCallback((draft) => api.saveDraft(draft), []);
const debouncedSave = useMemo(() => debounce(save, 1000), [save]);
useEffect(() => () => debouncedSave.cancel(), [debouncedSave]);
A decision list you can actually use
Match the tool to what the user is waiting for.
- Search box, autocomplete, live filter: debounce, roughly 300 ms. You only care about the final query.
- Autosaving a draft or a long form: debounce, one to two seconds, plus an immediate save on blur and before unload.
- Validating a username against the server as it is typed: debounce, and abort the previous request.
- Scroll position, sticky headers, progress bars: requestAnimationFrame gating, or throttle around 100 to 200 ms. Debounce would make it lag.
- Infinite scroll, lazy images: IntersectionObserver. No timing code at all.
- Window resize recalculating a layout: debounce if the work is expensive and only the final size matters, throttle if the UI must track the drag.
- Mouse move, drag, canvas painting: throttle or requestAnimationFrame, never debounce.
- A submit button that must not double fire: leading-edge debounce, or better, disable the button and rely on an idempotency key on the server.
Two closing cautions. Debouncing on the client is a user experience tool, not a rate limit. Anyone can open DevTools and call your endpoint in a loop, so if you need to protect a search API from abuse, rate limit it on the server as well. And check whether you need the timing code at all: for a search input, listening to the input event is already better than keyup because it fires once for paste, autofill and voice input, and a plain <form> with a submit button sends exactly one request without any timers.
Finally, if the app already depends on lodash, lodash.debounce and lodash.throttle handle leading and trailing edges, cancel and flush, and debounce's maxWait option, correctly. Writing your own is a good exercise and a common interview question, but for production code the well-tested version has fewer edges.
