Topic 9.7
Debounce and Throttle
In one line
Some events fire dozens of times a second (typing, scrolling, resizing). Debounce waits until the events stop for a moment and then runs once; throttle runs at most once per time window while they keep coming. Both are small functions built from closures and timers.
Think of it like this
A lift and a ticket machine. A lift door waits until people stop walking in: each new person resets the wait, and the door closes only after a quiet moment. That's debounce. A ticket machine in a busy station serves one person every 10 seconds, however many are pushing: that's throttle. Debounce asks "have they finished?"; throttle says "no more than this often".
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- High-frequency event
- An event that can fire many times a second, like
scroll,input,pointermoveorresize. - Debounce
- Wait until calls stop for a set time, then run once with the latest values.
- Throttle
- Run at most once in each time window, however often it is called.
- Timer
- A delayed call set with
setTimeout, which can be cancelled withclearTimeout. - Closure
- A function that remembers the variables around where it was created, even after that code finished.
- Leading call
- Running at the start of a burst of events.
- Trailing call
- Running at the end of a burst of events, after things go quiet.
- Wrapper
- A new function that adds behaviour (like waiting) around your original function.
Step by step
01See the waste
Without any control, every keystroke runs the search. Typing "mango" makes five calls; four of them are for half-typed words nobody wanted.
input.addEventListener("input", () => {
search(input.value); // m, ma, man, mang, mango
});02Write debounce
The returned function clears the previous timer and sets a new one. Only the last timer survives to fire. ...args collects whatever arguments the wrapper was called with and passes them on.
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delay);
};
}03Write throttle
Remember the last time it ran. If not enough time has passed, skip the call. (This simple version runs on the leading edge; the pro notes show how to keep the last call too.)
function throttle(fn, interval) {
let last = -Infinity;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}04Wrap once, use in the listener
Build the wrapper once and pass it, or call it from the listener.
const debouncedSearch = debounce(search, 300);
input.addEventListener("input", () => debouncedSearch(input.value));
window.addEventListener("scroll", throttle(updateProgressBar, 100), { passive: true });05Compare them on a timeline
Events arrive in a burst. Debounce fires once after the burst ends. Throttle fires at the start and then at most every interval during the burst.
Try it yourself
- 1
Type slower than the delay
In the first example, change
i * 60toi * 400. Predict how many searches run now, and for which words. Then run it. - 2
Change the window
In the second example, change the interval from 100 to 60. Predict the handled times before running.
- 3
Make a new wrapper each time (on purpose)
In the first example, change the listener to
debounce(search, 300)(input.value);and deletedebouncedSearch. Predict how many searches run, then explain why.
Code & diagrams
Three input events, one search, 300 ms after the last letter. Type in the box yourself: pause, then type fast, and count the searches.
Preview
Console
Nothing logged yet.Expected console output
input: m
input: ma
input: man
search #1 for "man" -> [ 'mango', 'mangosteen' ]With real timers the exact numbers would wobble by a few milliseconds. A fake clock makes the behaviour exact, which is how test libraries (such as Jest's and Vitest's fake timers) test debounce and throttle.
Preview
Console
Nothing logged yet.Expected console output
handled at 0 ms, scrollY = 0
handled at 100 ms, scrollY = 200
handled at 200 ms, scrollY = 400
handled at 300 ms, scrollY = 600
13 events, 4 handledScroll inside the box for a few seconds and compare the three counters. The green bar is driven by the per-frame handler, so it moves smoothly but never more often than the screen can show.
Preview
Console
Nothing logged yet.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Create the debounced function inside the listener
Write input.addEventListener("input", () => debounce(search, 300)(input.value));.
Break #2
Throttle that drops the last update
Use the simple leading-only throttle for a "save scroll position" feature, and scroll then stop quickly.
Myth vs fact
Myth
Debounce and throttle are the same thing.
Fact
Debounce waits for a pause and runs once at the end; during constant activity it never runs. Throttle runs regularly during constant activity. Pick by asking: do I need the final value, or steady updates?
Myth
Debouncing a search fixes out-of-order results.
Fact
It reduces requests, but a slow reply for "ma" can still arrive after the reply for "man" and overwrite it. Cancel old requests with AbortController (Topic 11.2) or ignore replies for queries that are no longer current.
Myth
A smaller delay is always better.
Fact
Too small and you're back to too many calls; too big and the UI feels laggy. 150–300 ms suits search boxes; for scroll visuals, one per animation frame is the natural limit.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A throttle with a trailing call: keep the latest
args, and if a call is skipped, set one timer for the remaining time (interval - (now - last)) that runsfnwith the latest args. That gives a run at the start, regular runs during, and one at the end. Lodash'sthrottleis actually itsdebouncewithmaxWaitequal to the wait. - ▸
Use
thisand arguments carefully:fn.apply(this, args)inside a regularfunctionwrapper preserves thethisthe wrapper was called with (the element, when used directly as a listener). An arrow function wrapper would lose it (Topic 7.1). - ▸
Give debounced functions a way out: a
cancel()method (clear the timer, for example when a component is removed) and aflush()method (run the pending call now, for example before the page unloads so a draft isn't lost). Many libraries provide both. - ▸
In a React or Vue component, recreating the debounced function on every render has the same bug as creating it inside a listener: wrap it with
useMemo/useRef(or equivalent) so the same instance lives across renders, and cancel it on unmount.
Remember this
- 1
The problem: high-frequency events. Typing in a search box fires
inputfor every key. Scrolling firesscrollmany times a second, and so dopointermove,wheeland windowresize. If each one starts a network request or heavy work (sorting a big list, measuring the layout), the page stutters and the server is flooded. Most of those calls are wasted: only the final search term or the latest scroll position matters. - 2
Debounce delays a function until a pause: each call cancels the pending one and starts a new timer (Topic 10.2 explains timers). Only when no call has arrived for
delaymilliseconds does the function actually run, once, with the latest arguments. Typing "mango" quickly makes fiveinputevents but only one search, 300 ms after the last key. Use it when you care about the final value: search-as-you-type, auto-saving a draft, validating a username, recalculating after a window resize ends. - 3
Throttle limits a function to at most once per interval, however often it is called. If a scroll handler is throttled to 100 ms and the user scrolls for 2 seconds, it runs about 20 times at a steady rhythm instead of hundreds. Use it when you need regular updates during a continuous action: updating a reading-progress bar, checking if the user is near the bottom for infinite scroll, sending pointer positions in a multiplayer drawing app.
- 4
Both are built from a closure (Topic 3.6: a function that remembers variables from where it was created).
debounce(fn, delay)returns a new function that remembers atimerId;throttle(fn, interval)returns one that remembers when it last ran. You call the returned wrapper from your listener. Create the wrapper once, outside the listener; creating a new one on every event gives each event its own fresh memory, so nothing is ever skipped. - 5
There are variations. Leading debounce runs on the first call and then ignores calls until a pause (good for stopping double-submits of a button). Trailing (the usual kind) runs after the pause. A good throttle also runs once more at the end (a trailing call) so the final position isn't lost. Libraries like Lodash offer
debounce(fn, wait, { leading, trailing, maxWait })with all of these; writing your own 8-line version is a classic interview question. - 6
For work that changes what's drawn on screen, there's a third tool: **
requestAnimationFrame** (Topic 11.7) runs your function just before the browser's next paint, usually 60 times a second (more on fast screens). Throttling scroll-driven visual updates to one per frame avoids doing work the user can never see. And for some jobs, a dedicated API is better than any event:IntersectionObserverfor "is this element visible?" andResizeObserverfor element size changes (Topic 11.6).
Explain it without notes
Explain debounce and throttle, and give one use case for each.
Implement a debounce(fn, delay) function and explain how it works.
Why is the debounced function usually created once, outside the event listener?
When would you use requestAnimationFrame instead of a time-based throttle?
Practice
Debounce an "autosave" so it logs saved: <text> 500 ms after the user stops typing in a <textarea id="note">. Simulate two quick input events.
Write a once(fn) helper (a close cousin of these) that runs fn the first time and returns the first result on every later call.
Trade-offs
- ↔
Debounce minimises calls but adds a delay before anything happens and never runs during non-stop activity; throttle gives steady feedback but does more total work and needs a trailing call to capture the final state.
- ↔
Writing your own helpers keeps bundles tiny and is easy to read; a library version handles edge cases (
maxWait,cancel,flush, leading and trailing) that hand-written ones often miss.
Done when you can
I can explain debounce and throttle with a real example of each.
I can write
debounceandthrottlefrom memory using closures and timers.I create the wrapper once, outside the listener.
I can choose between debounce, throttle and
requestAnimationFrame.I mark scroll listeners passive and keep them cheap.