Type "javascript" into an unthrottled search box and watch the network tab: an HTTP request for j, another for ja, another for jav, ten in total, nine of them thrown away before the response even lands. The server did ten times the work the feature needed, and the UI flickers with results for a query the user abandoned two keystrokes ago. The fix is two small, deceptively simple functions — debounce and throttle — and the part that trips people up isn't writing them, it's knowing exactly when each one fires.
What you'll learn
By the end of this guide you'll be able to:
- Explain the precise difference between debounce and throttle, not just "they both slow things down"
- Write a correct debounce and a correct throttle from scratch, including leading/trailing edge behavior
- Choose the right one for search inputs, scroll handlers, resize handlers, and button clicks
- Avoid the memory leaks and stale-closure bugs both patterns cause in React and vanilla JS alike
- Use a copy-paste utility with
cancel()andflush()support
Who this is for: you write JavaScript day to day, you've attached an event listener before, and you've either hand-rolled a setTimeout hack for this exact problem or reached for lodash without fully trusting what its defaults do.
Contents
- Why debounce and throttle exist
- The mental model: a bouncer, not a filter
- Stage 1: debounce from scratch
- Stage 2: throttle from scratch
- Stage 3: leading and trailing edges
- Stage 4: cancel, flush, and cleanup
- Stage 5: using them in React
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Why debounce and throttle exist
Here's the naive version of a live search box — the one almost everyone writes first:
// the wrong way — fires a request on every single keystroke
searchInput.addEventListener("input", (event) => {
fetchResults(event.target.value); // one HTTP request per keystroke
});
Type a six-letter word at a normal pace and this fires six requests in well under a second, five of which are already obsolete by the time their responses arrive. Worse, network responses don't always resolve in the order they were sent — a slow response for jav can arrive after the fast response for javascript, and now the screen is showing stale results for a query the user already replaced. The bug isn't visible in a quick manual test because your laptop and the API are both fast; it shows up for a real user on a real network, and by then it looks like "search is flaky" rather than "search fires way too often."
The same shape of problem hits scroll and resize handlers, just with a different failure mode. A scroll event can fire dozens of times per second. If the handler does anything nontrivial — reading getBoundingClientRect(), updating layout, running a chunk of business logic — the page starts dropping frames and scrolling turns jerky, even though nothing is technically "broken."
Both problems come from the same root cause: the event source fires far more often than the response actually needs to run. Debounce and throttle are two different answers to "how often is often enough," and they are not interchangeable.
The mental model: a bouncer, not a filter
The mental model: neither function filters events — every event still reaches your wrapper and every event still runs a check. What changes is how often the check lets the real work through, and the two functions use opposite strategies for deciding that.
- Debounce says "wait for quiet." Every call resets a timer. The wrapped function only runs once the calls actually stop for the configured delay. Think of an elevator door: every time someone walks up, the door resets its close timer. It only closes once nobody has approached it for a few seconds.
- Throttle says "at most once per interval." It doesn't care whether calls are still coming in — it just refuses to let the wrapped function run again until a fixed amount of time has passed since the last time it ran. Think of a metronome, or a bouncer who lets one person through the door every two seconds regardless of how long the line is.
That single distinction — "wait for silence" versus "space it out at a fixed rate" — explains almost every behavior difference in the rest of this guide. Debounce is right when you only care about the final state (the finished search query). Throttle is right when you need regular updates during continuous activity (a scroll position that should keep updating while the user scrolls).
Stage 1: debounce from scratch
The smallest correct debounce is short — a closure holding one timer ID:
function debounce(fn, delayMs) {
let timeoutId; // lives across calls thanks to the closure
return function debounced(...args) {
clearTimeout(timeoutId); // cancel whatever was pending
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
};
}
const debouncedSearch = debounce((query) => fetchResults(query), 300);
searchInput.addEventListener("input", (e) => debouncedSearch(e.target.value));
Key concept: every call to
debouncedcancels the previous pending timer and starts a new one.fnonly ever actually runs if 300ms pass with no new call in between — which is exactly "wait for quiet," implemented as literally as possible.
Type "js" quickly and debounced runs twice (once per keystroke) but fn runs zero times until you stop — then it runs exactly once, 300ms after your last keystroke, with the final value of query. That's the whole mechanism. Everything else in this guide is a variation on this six-line function.
Stage 2: throttle from scratch
Throttle needs to track when it last ran, not whether a timer is pending:
function throttle(fn, intervalMs) {
let lastRun = 0; // timestamp of the last time fn actually executed
return function throttled(...args) {
const now = Date.now();
if (now - lastRun >= intervalMs) {
lastRun = now;
fn.apply(this, args);
}
};
}
const throttledOnScroll = throttle(() => updateScrollProgress(), 100);
window.addEventListener("scroll", throttledOnScroll);
Key concept: this implementation runs
fnimmediately on the very first call (becausenow - lastRunstarts effectively infinite), then ignores every call untilintervalMshas elapsed, at which point the next call through gets to run. Calls that arrive during the "cooldown" are dropped entirely — not queued, not delayed, just discarded.
That last detail matters: with this specific implementation, if the burst of calls stops during a cooldown window, the very last call in the burst is simply lost — fn doesn't get one final run with the latest arguments. Stage 3 fixes that.
Stage 3: leading and trailing edges
"Leading edge" means running on the first call in a burst; "trailing edge" means running once more after the burst ends, with the latest arguments. The debounce in Stage 1 is trailing-only. The throttle in Stage 2 is leading-only. A production-grade version usually supports both, because dropping the trailing call (throttle) or delaying every call including the first one (debounce) is sometimes the wrong tradeoff:
function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
let lastRun = 0;
let timeoutId = null;
let lastArgs = null;
return function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastRun);
lastArgs = args;
if (remaining <= 0) {
if (leading || lastRun !== 0) {
lastRun = now;
fn.apply(this, args);
}
} else if (trailing && !timeoutId) {
timeoutId = setTimeout(() => {
timeoutId = null;
lastRun = Date.now();
fn.apply(this, lastArgs);
}, remaining);
}
};
}
Key concept:
leadingcontrols whether the very first call in a burst runs immediately;trailingcontrols whether one extra call fires after the burst goes quiet, using whatever arguments arrived last. lodash's_.throttledefaults to{ leading: true, trailing: true }, and its_.debouncedefaults to{ leading: false, trailing: true }— which is exactly why debounce "feels like" it only fires at the end, while throttle "feels like" it fires immediately and then periodically.
Stage 4: cancel, flush, and cleanup
A debounce or throttle you can't cancel is a liability the moment its owner disappears — a component unmounts, a modal closes, a request is superseded. Attach the controls directly to the returned function:
function debounce(fn, delayMs) {
let timeoutId;
function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
}
debounced.cancel = () => clearTimeout(timeoutId); // drop the pending call
return debounced;
}
const debouncedSave = debounce(saveDraft, 500);
debouncedSave("draft text");
debouncedSave.cancel(); // "draft text" will never be saved
flush() is the mirror image: run the pending call right now instead of waiting or dropping it — useful when the user explicitly submits a form while a debounced autosave is still pending, so the two writes don't race.
Stage 5: using them in React
The trap in React isn't the debounce function itself — it's where you create it. Creating a new debounced function on every render breaks the whole mechanism, because each render's closure has no memory of the previous render's timer:
// the wrong way — a brand-new debounce (and a brand-new timer) every render
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedSearch = debounce((q) => fetchResults(q), 300); // recreated every render
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value); // this instance's timer never gets to fire before a new one replaces it
}}
/>
);
}
Create the debounced function once, with useMemo or useRef, and cancel it on unmount:
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedSearch = useMemo(() => debounce((q) => fetchResults(q), 300), []);
useEffect(() => {
return () => debouncedSearch.cancel(); // no fetch after this component is gone
}, [debouncedSearch]);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
Key concept: the debounced function must outlive individual renders (created once, stored in a ref or memoized with an empty dependency array) and must be explicitly cancelled in a cleanup function, or its pending timer will call
fetchResultson state that no longer exists.
Edge cases and gotchas
-
Stale closures. If
fninside a debounce/throttle closes over a variable that changes between calls (a piece of state, a prop), the call that eventually fires can use an outdated value.useCallback/useRefpatterns exist specifically to solve this in React; in vanilla JS, pass the current value as an argument rather than relying on the closure. -
thisbinding. The hand-written implementations above usefn.apply(this, args)so a debounced/throttled method still sees the rightthis. If you strip that out, callingdebounce(obj.method, 300)()silently breaksthisinsidemethod. -
Debouncing an
asyncfunction doesn't cancel in-flight work. Debounce delays calling the function; it does nothing about afetchthat's already in progress from a previous call. Pair debounce with anAbortControllerif a slow, superseded request could still resolve and overwrite a newer one. -
Race conditions between the trailing call and unmount. A debounce's
setTimeoutkeeps a reference tofnalive even after the component that created it is gone. Withoutcancel()on cleanup, the trailing call still fires and can throw ("cannot update state on an unmounted component") or write to a resource that no longer applies. -
Throttle intervals and animation. A
scrollormousemovethrottle set to a fixed millisecond interval can visibly stutter, because it's not synchronized with the browser's paint cycle. For anything visual, preferrequestAnimationFrame-based throttling: run at most once per frame instead of once per N milliseconds. -
Testing. Both patterns depend on real time passing, which makes tests flaky if you
sleep. Use fake timers (jest.useFakeTimers()/vi.useFakeTimers()) and advance them explicitly (jest.advanceTimersByTime(300)) instead of waiting on the wall clock. - Debounce delay versus perceived responsiveness. A 300ms debounce feels instant to most users; anything above ~500ms on a search-as-you-type field starts to feel sluggish, because the user has already mentally "sent" the query.
Best practices (when (not) to use them)
Reach for debounce when you only care about the value once activity settles: search-as-you-type, autosave, form validation that shouldn't run on every keystroke, resize-triggered layout recalculation where only the final size matters.
Reach for throttle when you need periodic updates during continuous activity, not just at the end: scroll-position tracking, a progress indicator following mousemove, rate-limiting how often a "user is typing" indicator pings a server, infinite-scroll trigger checks.
Avoid both when the action needs to feel instantaneous every single time — a button click, an "add to cart," a keyboard shortcut. Delaying or dropping those erodes trust in the UI even if it's technically more "efficient." If a click handler is slow, fix the handler; don't debounce the click.
Don't stack them by accident. Wrapping an already-throttled handler in another library's debounce (or vice versa) is a common cause of "my scroll handler feels randomly laggy" bugs — pick one strategy per event source and be deliberate about the delay.
FAQ
What's the actual difference between debounce and throttle?
Debounce waits for a pause in activity and then runs once; throttle runs at a fixed maximum rate regardless of whether activity is still ongoing. Debounce answers "what's the final state?"; throttle answers "give me periodic updates while this keeps happening."
Does lodash's _.debounce behave differently from a hand-rolled one?
Functionally, a correct hand-rolled trailing-edge debounce matches _.debounce's default behavior (leading: false, trailing: true). lodash additionally ships cancel(), flush(), and a maxWait option (a ceiling on how long calls can be delayed even under continuous activity) — genuinely useful extras, not a different core algorithm.
Can I debounce an async function?
Yes, but debounce only controls when the call happens — it has no knowledge of what the async function does afterward. If an earlier (superseded) call's promise resolves after a later one, you can still get out-of-order results unless you also track "is this the latest call" or cancel the earlier request with an AbortController.
Should I use requestAnimationFrame instead of throttle for scroll or resize?
For anything that updates visuals (position, size, opacity), yes — requestAnimationFrame throttling caps the work at once per paint, which is both smoother and never more work than the browser can actually display. Millisecond-based throttling is still the right tool for non-visual rate-limiting, like capping how often you ping an analytics endpoint.
How do I cancel a debounced or throttled call?
Attach a .cancel() method to the returned function (Stage 4) and call it — in setTimeout's case that's a clearTimeout; for lodash, _.debounce and _.throttle both return functions with a built-in .cancel().
Cheat sheet
| Task | Code | Notes |
|---|---|---|
| Debounce (trailing only) | debounce(fn, 300) |
Runs once, 300ms after calls stop. Best for search/autosave. |
| Throttle (leading + trailing) | throttle(fn, 100, { leading: true, trailing: true }) |
Runs immediately, then at most every 100ms, plus once more after the burst ends. |
| Cancel a pending call | debounced.cancel() |
Clears the timer; the delayed call never runs. |
| Flush a pending call now | debounced.flush() |
Runs the pending call immediately instead of waiting. |
| Visual/animation rate-limit |
requestAnimationFrame loop |
Caps work to once per paint; smoother than a fixed-ms throttle for scroll/resize. |
| React: create once | useMemo(() => debounce(fn, ms), []) |
Never recreate inside the render body. |
| React: cleanup | useEffect(() => () => debounced.cancel(), []) |
Prevents calls firing after unmount. |
// the whole pattern, copy-paste ready
function debounce(fn, delayMs) {
let timeoutId;
function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
}
debounced.cancel = () => clearTimeout(timeoutId);
return debounced;
}
function throttle(fn, intervalMs, { leading = true, trailing = true } = {}) {
let lastRun = 0;
let timeoutId = null;
let lastArgs = null;
function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastRun);
lastArgs = args;
if (remaining <= 0) {
if (leading || lastRun !== 0) {
lastRun = now;
fn.apply(this, args);
}
} else if (trailing && !timeoutId) {
timeoutId = setTimeout(() => {
timeoutId = null;
lastRun = Date.now();
fn.apply(this, lastArgs);
}, remaining);
}
}
throttled.cancel = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
return throttled;
}
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Key takeaways
- Debounce waits for quiet and runs once at the end; throttle runs on a fixed schedule no matter how long the activity continues.
- Every event still reaches the wrapper — what changes is how often the real work behind it is allowed to run.
- Use debounce for "what's the final value" (search, autosave); use throttle (or
requestAnimationFrame) for "keep me updated while this continues" (scroll, resize, drag). - Both need
cancel()wired into cleanup — an uncancelled debounce or throttle is a call waiting to fire on a component that no longer exists. - Create the wrapped function once, not on every render.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
That search box from the opening paragraph needs exactly one line changed — wrap the handler in debounce(fetchResults, 300) — and the ten wasted requests become one, fired the moment the user actually stops typing. Which of your own event handlers is still firing ten times more than it needs to?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (1)
Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.
The distinction between temporal suppression and execution scheduling is explained very well here. One advanced issue I would add is that debouncing network calls should be coupled with request cancellation and response versioning. A debounced fetch can still leave an earlier request in flight, allowing an out of order response to overwrite newer state. AbortController plus a monotonically increasing request sequence solves that cleanly.
For React, I also prefer separating event rate limiting from state synchronization. Keep the debounced callback stable, store mutable dependencies in refs, and treat cleanup as part of the lifecycle contract.
For high frequency visual events, requestAnimationFrame is particularly important because JavaScript execution should align with the browser rendering pipeline rather than an arbitrary timer interval.
A great tutorial overall. The next interesting layer would be benchmarking these strategies under real event pressure with PerformanceObserver and flame charts.