JavaScript Debounce & Throttle Table
| Piece | What it does | Field note |
|---|---|---|
debounce (trailing) | One call after quiet | Timer resets on every event - fn runs wait ms after the LAST one; the search-box default |
leading edge | Fire first, then lock | Run on the first event, ignore until quiet - the double-click-proof submit/pay button |
throttle | Sample the flood | At most one call per interval while events continue - scroll sync, drag, pointermove |
rAF throttle | Once per frame | Repaint-aligned coalescing with zero interval math - the platform-native scroll floor |
cancel() | Drop the pending call | The debounce that outlives its component fires into the void - cancel on teardown |
flush() | Run now if pending | Force the trailing call before navigation or submit - the lost-autosave fix |
this & arguments | Context survives | setTimeout drops both - capture per call, fn.apply(ctx, args) when the timer fires |
debounce vs throttle | The decision | Need the final value: debounce. Need rationed continuity: throttle - decide per handler |
Debouncing collapses a burst of events into one call after things go quiet: every event resets a timer, and the wrapped function runs only when the events have stopped for the wait period. It is the standard answer to search-as-you-type flooding your API, resize handlers relaying out the page sixty times a second, and autosave firing on every keystroke instead of at the end of a thought.
Bottom line: debounce and throttle answer different questions, and picking the wrong one is visible in the UX. Debounce = wait for quiet - you need the FINAL value (the finished search query, the settled window size). Throttle = sample the flood - the stream matters but must be rationed (scroll position sync, pointermove drag). Ask one question per handler: do I need the last event, or a representative sample of all of them? And remember the free tier: requestAnimationFrame is the platform-native throttle - once per frame, repaint-aligned, zero interval math.
The honest part: a debounced function is a pending timer, and pending timers outlive the code that scheduled them. The classic production bug is the debounce that fires into a torn-down component - the user navigates away, THEN the trailing call runs and writes state nothing reads anymore. Production-grade debounce attaches cancel() (drop the pending call) and flush() (run it now), and preserves this and arguments - the two things a naive arrow-wrapped setTimeout quietly drops.
How to use
- Basic trailing debounce in six lines: const deb = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; - every call clears the previous timer, so fn runs once, wait ms after the LAST event. This is the form for search inputs, autosave, and resize.
- Leading edge for buttons: fire on the FIRST event, then ignore everything until quiet - the double-click-proof form for submit, add-to-cart, and pay buttons. The test: if a second event arriving early means damage (double charge) or noise (duplicate toast), you want leading edge, not trailing.
- Throttle for continuous streams: guarantee at most one call per interval WHILE events keep coming - scroll-position syncing, drag previews, pointermove highlights. A debounced scroll handler feels broken (it waits for you to stop scrolling); a throttled one updates on a steady cadence.
- Attach cancel() and flush(): cancel drops the pending call - call it on unmount/teardown so the trailing invocation never fires into a dead component. flush runs it now if pending - call it on submit or navigation so the debounced autosave is not lost mid-flight. Libraries like lodash expose both; hand-rolled versions usually forget them.
- Use rAF for per-frame work: for scroll/resize layout work, coalesce with requestAnimationFrame instead of timer math - one run per frame, aligned to the repaint cycle, and the handler stops automatically when the tab is hidden (timers do not). Pair with passive listeners so the scroll thread never waits on you.
Frequently asked questions
How do I decide between debounce and throttle?
One question: do you need the final value or a rationed stream? Search box, resize, autosave, form validation - the user stops and THEN the result matters: debounce. Scroll progress bar, drag position, infinite-scroll sentinel, mousemove highlight - the interaction is continuous and you need updates throughout, just not 200 of them per second: throttle. The misfire symptoms are symmetrical: a debounced scroll bar stutters in steps (it only updates when you stop), and a throttled search box fires queries mid-typing for garbage prefixes like 'jav'.
Why does my debounced function lose this and the arguments?
Because a naive implementation detaches the call: setTimeout(fn, ms) invokes fn with window as this and no arguments; setTimeout(() => fn(x), ms) hardcodes the FIRST call's arguments. The fix is passing the context through: return function (...args) { const ctx = this; clearTimeout(t); t = setTimeout(() => fn.apply(ctx, args), ms); }; - capture this and args per call, invoke them together when the timer finally fires. The symptom of getting it wrong: event handlers crash on missing this, and every debounced call uses the first event's stale payload.
What wait time should I use?
Match the rhythm of the user, not the cost of the API: 100-300ms for autocomplete (feels instant, cuts query volume 5-10x), 300-800ms for autosave and validation (a typing pause, not a keystroke), 100-200ms for resize relayouts, 1s+ only for genuinely expensive operations like API-quota-bound calls. Below ~100ms you pay the complexity and nobody can perceive the difference; the measuring stick is 'how long is a pause in this interaction?', not how slow your backend is - a slow backend is what the loading state is for.
Is there a native debounce in JavaScript?
No - the platform ships the primitives (setTimeout, clearTimeout, requestAnimationFrame) and debouncing stays a 15-line utility, which is why lodash's _.debounce and friends exist: the wheel gets reinvented badly, and the classic reinvention bug is forgetting clearTimeout, so every event schedules its own call and fn fires once per keystroke after the delay - accidentally a throttle with extra lag. The closest natives: rAF coalesces per frame (the scroll case), and ResizeObserver already debounces resize observations for you. Anything beyond that, write the utility once with cancel/flush and reuse it.