Debounce and throttle in JavaScript
Debounce postpones a function until after a pause in calls. Throttle limits it to at most once per time interval.
Theory
TL;DR
- Debounce waits for silence: fires once after calls stop, not during them
- Throttle keeps a beat: fires at a fixed rate during continuous activity
- Analogy: debounce is a restroom that locks for 5 minutes after each use; throttle is a coffee refill available every 5 minutes max
- User stops then acts? Debounce. Need a steady rate? Throttle.
Quick example
// Debounce: fires once after calls stop
const debounce = (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
// Throttle: fires at most once per delay
const throttle = (fn, delay) => {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn(...args);
}
};
};Both are closures. debounce resets a timer on every call. throttle checks a timestamp and drops calls that arrive too soon.
Key difference
Debounce collects a burst of calls and fires exactly once, after the burst ends. That last word is the whole point. Throttle does not wait for an end. It fires at the start, then once more per interval, for as long as calls keep coming.
When to use
- User typing in a search box → debounce (one API call after they pause, not per keystroke)
- Window scroll or resize → throttle (update layout at a steady pace)
- Form submit button → throttle (block duplicate submissions)
- External API with rate limits → throttle (space requests evenly)
- Field validation while typing → debounce (check after pause, not mid-word)
Comparison table
| Aspect | Debounce | Throttle |
|---|---|---|
| When it fires | After last call + delay | Immediately, then once per interval |
| During rapid calls | Resets each time, fires once at end | Fires at fixed intervals |
| Total executions per burst | Usually 1 | Multiple, rate-limited |
| Default behavior | Trailing (after silence) | Leading (first call goes through) |
| Best for | Autocomplete, auto-save, validation | Scroll, mousemove, API polling |
How it works internally
debounce calls clearTimeout on every invocation. That cancels the pending callback. Only the last call survives, because nothing cancels it. throttle skips the timer entirely in its basic form. It uses Date.now() to compare the current time against the last execution. If the gap is smaller than the delay, the call is dropped.
One thing I got wrong early on: throttle fires immediately on the first call, because lastCall starts at 0, so Date.now() - 0 is always larger than any realistic delay. The first call always goes through.
Both patterns rely on the browser event loop. Timer callbacks run in the macrotask queue, after the current synchronous code finishes.
Common mistakes
Mistake 1: No cleanup on unmount in React
// Wrong: timer fires after component is gone
useEffect(() => {
debouncedFetch(query);
}, [query]);
// Correct: cancel on cleanup
useEffect(() => {
const timer = setTimeout(() => fetchData(query), 300);
return () => clearTimeout(timer);
}, [query]);Without cleanup, the timer calls setState on an unmounted component. That throws warnings in dev mode and can break server-side rendering.
Mistake 2: New debounced function on every render
// Wrong: new function each render means the timer resets constantly
function SearchInput() {
const handleChange = debounce((val) => search(val), 500);
return <input onChange={handleChange} />;
}
// Correct: create once
function SearchInput() {
const handleChange = useMemo(
() => debounce((val) => search(val), 500),
[]
);
return <input onChange={handleChange} />;
}Mistake 3: Expecting throttle to skip the first call
const throttled = throttle(fn, 1000);
throttled(); // Fires immediately (leading edge)
throttled(); // Skipped
throttled(); // SkippedLodash _.throttle fires on the leading edge by default. Pass { leading: false } to change this.
Mistake 4: Losing this context
// Wrong: `this` is lost inside the wrapper
function debounce(fn, delay) {
let timer;
return function() {
clearTimeout(timer);
timer = setTimeout(fn, delay); // `this` not forwarded
};
}
// Correct
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay); // ✅
};
}Mistake 5: Nesting debounce inside throttle
// Broken: inner debounce resets every time throttle fires
const broken = throttle(() => debounce(save, 500)(), 1000);
// save is never calledOne wrapper is enough. Nesting them causes the inner timer to reset on every outer call.
Real-world usage
- React search bars use the
use-debouncehook (same pattern as Vercel commerce templates) - Lodash
_.debounceand_.throttleappear across most large npm projects for scroll and input handling express-rate-limitin Node.js/Express follows the throttle pattern for API endpoints- For scroll and resize at 60fps,
requestAnimationFrameis often a better fit than a fixed-delay throttle - Native
ResizeObserverremoves the need for manual throttling of resize events in modern browsers
Follow-up questions
Q: Implement debounce from scratch.
A: Use clearTimeout and setTimeout inside a closure. Preserve this and args with .apply(this, args). The Quick example above shows the minimal version.
Q: What happens when delay is 0?
A: Debounce fires on the next event loop tick after each call. Throttle passes through every call, because Date.now() - 0 is always at least 0.
Q: How do you fix a memory leak from debounce in React?
A: Return a cleanup function from useEffect that calls clearTimeout. Store the timer ID in a ref with useRef so it persists across renders.
Q: What is the difference between leading and trailing edge in throttle?
A: Leading fires on the first call immediately. Trailing fires at the end of the interval with the last set of arguments. Lodash _.throttle does both by default, which causes an extra call at the end of a burst that surprises most people.
Q: How do you debounce an async function?
A: The wrapper returns void, not a Promise. To get the resolved value, pass it through a callback, or track the latest Promise in a ref and resolve it separately. Multiple in-flight promises can race if you are not careful.
Examples
Basic: debounce for a search input
const debounce = (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
const input = document.querySelector('#search');
const search = debounce((event) => {
console.log('API call with:', event.target.value);
// fetch(`/api/search?q=${event.target.value}`)
}, 400);
input.addEventListener('input', search);
// Typing 'hello' triggers 5 input events
// Result: 1 API call, 400ms after the last keystrokeWithout debounce, typing five characters produces five API calls. With it, one call arrives after the user pauses.
Intermediate: throttled scroll handler for infinite scroll
const throttle = (fn, delay) => {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn(...args);
}
};
};
const handleScroll = throttle(() => {
const nearBottom =
window.scrollY + window.innerHeight >= document.body.scrollHeight - 100;
if (nearBottom) {
loadMoreItems();
}
}, 200);
window.addEventListener('scroll', handleScroll);
// Max 5 checks per second, regardless of scroll speedA fast scroll without throttle fires hundreds of events per second. At 200ms you get five checks. That is enough for infinite scroll to feel instant without hammering the browser.
Advanced: React custom hook with proper cleanup
import { useCallback, useRef, useEffect } from 'react';
function useDebounce(callback, delay) {
const timerRef = useRef(null);
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return useCallback((...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callback(...args), delay);
}, [callback, delay]);
}
function SearchBox() {
const handleSearch = useDebounce((value) => {
fetch(`/api/search?q=${value}`).then((res) => res.json());
}, 300);
return (
<input
type='text'
onChange={(e) => handleSearch(e.target.value)}
placeholder='Search...'
/>
);
}
// useEffect cleanup cancels the timer on unmount
// No setState calls after the component is goneThe useEffect cleanup is the piece most hand-rolled implementations skip. Without it, a user who navigates away mid-typing triggers a fetch that tries to update state on an unmounted component.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.