← blog
JavaScriptAugust 31, 2026 · 14 min read

Debounce and Throttle in JavaScript: The Complete Guide

Learn debounce and throttle in JavaScript step by step, with worked examples, edge cases, and a copy-paste cheat sheet for search, scroll, and resize.

Parsa Jiravand · Frontend engineer · building bestpractic
Debounce and Throttle in JavaScript: The Complete Guide

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.

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() and flush() 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.

Here's the naive version of a live search box — the one almost everyone writes first:

JavaScript
1
2
3
4
// 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: 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).

The smallest correct debounce is short — a closure holding one timer ID:

JavaScript
1
2
3
4
5
6
7
8
9
10
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 debounced cancels the previous pending timer and starts a new one. fn only 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.

Throttle needs to track when it last ran, not whether a timer is pending:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
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 fn immediately on the very first call (because now - lastRun starts effectively infinite), then ignores every call until intervalMs has 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.

"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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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: leading controls whether the very first call in a burst runs immediately; trailing controls whether one extra call fires after the burst goes quiet, using whatever arguments arrived last. lodash's _.throttle defaults to { leading: true, trailing: true }, and its _.debounce defaults 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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
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.

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:

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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:

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 fetchResults on state that no longer exists.

  • Stale closures. If fn inside 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/useRef patterns exist specifically to solve this in React; in vanilla JS, pass the current value as an argument rather than relying on the closure.
  • this binding. The hand-written implementations above use fn.apply(this, args) so a debounced/throttled method still sees the right this. If you strip that out, calling debounce(obj.method, 300)() silently breaks this inside method.
  • Debouncing an async function doesn't cancel in-flight work. Debounce delays calling the function; it does nothing about a fetch that's already in progress from a previous call. Pair debounce with an AbortController if a slow, superseded request could still resolve and overwrite a newer one.
  • Race conditions between the trailing call and unmount. A debounce's setTimeout keeps a reference to fn alive even after the component that created it is gone. Without cancel() 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 scroll or mousemove throttle set to a fixed millisecond interval can visibly stutter, because it's not synchronized with the browser's paint cycle. For anything visual, prefer requestAnimationFrame-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.

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.

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."

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.

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.

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.

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().

TaskCodeNotes
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 calldebounced.cancel()Clears the timer; the delayed call never runs.
Flush a pending call nowdebounced.flush()Runs the pending call immediately instead of waiting.
Visual/animation rate-limitrequestAnimationFrame loopCaps work to once per paint; smoother than a fixed-ms throttle for scroll/resize.
React: create onceuseMemo(() => debounce(fn, ms), [])Never recreate inside the render body.
React: cleanupuseEffect(() => () => debounced.cancel(), [])Prevents calls firing after unmount.
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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; }

Runs right in your browser — poke at it and watch the concept react live.

  • 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.

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:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.