Back to News & Insights
JavaScript August 31, 2026 · 11 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.

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.

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

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

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

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.

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:

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:

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation