Debounce & throttle
Learn debounce, throttle, leading and trailing calls, maxWait, requestAnimationFrame throttling, and cancellation for noisy JavaScript events.
- 01Limit noisy handlersChoose debounce, throttle, or requestAnimationFrame throttling for input, scroll, resize, mouse, and pointer events.
- 02Implement robust wrappersPreserve
thisand arguments, support leading and trailing edges, addmaxWait, and expose.cancel()and.flush(). - 03Avoid production bugsKeep wrappers stable, clean them up on unmount, cancel stale requests, and test timelines with a fake clock.
Limit noisy event work
Some browser events are noisy by design. input, scroll, resize, mousemove, and pointermove can fire many times before the user sees one meaningful change. If every event triggers a request, layout read, expensive calculation, or re-render, the main thread gets crowded.
Debounce waits until calls stop for wait milliseconds, then runs once with the latest arguments. Throttle runs at most once per interval while calls continue. requestAnimationFrame throttle runs visual work at most once per rendered frame.
| Tool | Cadence | Use it for |
|---|---|---|
| Debounce | Runs after calls stop for wait ms, using the latest arguments. | Search-as-you-type, autosave, resize end, validation after typing. |
| Throttle | Runs at most once per interval, optionally at the leading and trailing edges. | Scroll position tracking, drag handlers, rate-limited buttons, analytics pings. |
| rAF throttle | Runs at most once per rendered frame with the latest arguments. | Pointer previews, scroll-linked visual updates, canvas or DOM transforms. |
| CSS alternative | Avoids JavaScript handlers entirely when the platform can do the work. | Scroll-driven animations, position: sticky, content-visibility, CSS transitions. |
The earlier timers lesson introduced clearing timeouts, and classic utilities showed tiny bonus versions with a fake clock. This lesson builds the production version: leading and trailing edges, maxWait, this and argument preservation, cleanup, and deterministic tests.
Why noisy events need backpressure
LIVE COUNTSBackpressure means the consumer chooses a sustainable pace. Here the consumer is the expensive handler. You can still listen to every raw event, but the work that touches the network, the DOM, or framework state should run at the cadence users can perceive.
const debounced = debounce(updateSearch, 400);const throttled = throttle(updateScroll, 300);const visual = rafThrottle(updatePointerPreview); function onNoisyEvent(value) { rawCount += 1; debounced(value); // waits for quiet throttled(value); // at most once per interval visual(value); // once per animation frame}- Raw
- 0
- Debounced
- 0
- Throttled
- 0
- rAF
- 0
- Events will appear here.
Type in the input or move inside the pointer pad. The same raw event feeds all three wrappers.
Try typing quickly, then pause. Raw events rise for every keystroke; the debounced count waits. Move the pointer in the pad: rAF throttling tracks the latest coordinates without trying to paint more often than the browser can render. This is the same reason the main thread lesson focuses on keeping each visible update small and scheduled deliberately.
Debounce waits for quiet
STEP THROUGHDebounce is the right mental model when the final value matters more than the intermediate values. Search boxes, autosave after typing, validation after the user pauses, and work after resizing all fit this shape.
Picture an elevator whose doors keep reopening while people step in. It leaves only after a quiet moment. That is debounce. A bus, by contrast, leaves on a schedule even if people keep arriving. That is throttle.
- In real life: Elevator doors wait for the last person before closing
- In JavaScript: Debounce resets the timer on every call
- In real life: The last person who entered decides when the door finally closes
- In JavaScript: The latest arguments are the ones used
- In real life: A bus leaves every fixed interval
- In JavaScript: Throttle runs at most once per interval
Where the analogy stops: Elevators and buses do not expose .cancel(), .flush(), this, or promise cancellation. JavaScript wrappers must handle those production details explicitly.
function debounce(fn, wait, options = {}, clock = { now: Date.now, setTimeout, clearTimeout }) { const leading = options.leading === true; const trailing = options.trailing !== false; const maxing = typeof options.maxWait === "number"; const maxWait = maxing ? Math.max(options.maxWait, wait) : undefined; let timerId, maxTimerId, lastArgs, lastThis, result; function invoke() { const args = lastArgs; const receiver = lastThis; lastArgs = lastThis = undefined; result = fn.apply(receiver, args); return result; } function finishWait() { timerId = undefined; if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); maxTimerId = undefined; if (trailing && lastArgs) return invoke(); lastArgs = lastThis = undefined; return result; } function finishMaxWait() { maxTimerId = undefined; if (timerId !== undefined) clock.clearTimeout(timerId); timerId = undefined; if (lastArgs) return invoke(); return result; } function debounced(...args) { const callLeading = leading && timerId === undefined; lastArgs = args; lastThis = this; if (timerId !== undefined) clock.clearTimeout(timerId); timerId = clock.setTimeout(finishWait, wait); if (maxing && maxTimerId === undefined) maxTimerId = clock.setTimeout(finishMaxWait, maxWait); return callLeading ? invoke() : result; } debounced.cancel = () => { if (timerId !== undefined) clock.clearTimeout(timerId); if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); timerId = maxTimerId = lastArgs = lastThis = undefined; }; debounced.flush = () => { if (timerId === undefined && maxTimerId === undefined) return result; if (timerId !== undefined) clock.clearTimeout(timerId); if (maxTimerId !== undefined) clock.clearTimeout(maxTimerId); timerId = maxTimerId = undefined; return trailing && lastArgs ? invoke() : result; }; return debounced;}Read it line by line. Lines 2 and 3 choose which edge can run. Lines 8 through 13 invoke with fn.apply(receiver, args), so methods keep their this value and callbacks receive the latest arguments. Line 38 restarts the quiet timer on every call. Line 39 starts a separate maxWait timer only once per burst, so continuous input can still produce progress.
Trailing debounce waits until calls stop, then invokes once with the latest arguments.
script
const fired = [];const search = debounce((term) => { fired.push(clock.now() + "ms " + term);}, 100, { trailing: true }, clock); search("a");clock.tick(40);search("ab");clock.tick(40);search("abc");clock.tick(99);clock.tick(1);console.log(fired.join(" | "));In the default timeline, calls happen at 0, 40, and 80 milliseconds. The first two timers are cleared. The last timer survives until 180 milliseconds, so only abc fires. Switch to leading and maxWait modes to see the other edges.
Throttle samples steadily
INTERVALSThrottle is for work that should keep happening during a stream, but not for every event. A scroll progress indicator, a drag handler, a rate-limited button, and analytics sampling all need periodic updates while the user is still moving.
function throttle(fn, interval, options = {}, clock = { now: Date.now, setTimeout, clearTimeout }) { const leading = options.leading !== false; const trailing = options.trailing !== false; let timerId, lastArgs, lastThis, lastInvokeTime, result; function invoke(time) { const args = lastArgs; const receiver = lastThis; lastArgs = lastThis = undefined; lastInvokeTime = time; result = fn.apply(receiver, args); return result; } function scheduleTrailing(remaining) { if (!trailing || timerId !== undefined) return; timerId = clock.setTimeout(() => { timerId = undefined; if (lastArgs) invoke(clock.now()); }, remaining); } function throttled(...args) { const time = clock.now(); lastArgs = args; lastThis = this; if (lastInvokeTime === undefined) { lastInvokeTime = time; return leading ? invoke(time) : (scheduleTrailing(interval), result); } const remaining = interval - (time - lastInvokeTime); if (remaining <= 0) { if (timerId !== undefined) clock.clearTimeout(timerId); timerId = undefined; return invoke(time); } scheduleTrailing(remaining); if (!trailing) lastArgs = lastThis = undefined; return result; } throttled.cancel = () => { if (timerId !== undefined) clock.clearTimeout(timerId); timerId = lastArgs = lastThis = lastInvokeTime = undefined; }; throttled.flush = () => { if (timerId === undefined) return result; clock.clearTimeout(timerId); timerId = undefined; return lastArgs ? invoke(clock.now()) : result; }; return throttled;}Throttle keeps a lastInvokeTime. If the interval has elapsed, it invokes now. If the interval is still closed and trailing is enabled, it schedules one timer for the remaining time. Later calls replace the saved arguments, so the trailing edge uses the newest event data.
Throttle samples a noisy stream at a steady cadence. Leading and trailing decide which edges of each interval can run.
script
const fired = [];const track = throttle((position) => { fired.push(clock.now() + "ms " + position);}, 100, { leading: true, trailing: true }, clock); track("top");clock.tick(20);track("middle");clock.tick(50);track("bottom");clock.tick(30);clock.tick(20);track("after");clock.tick(80);console.log(fired.join(" | "));Notice the difference from debounce. The throttled handler runs at 0ms, 100ms, and 200ms during a continuous stream. It does not wait for complete quiet; it samples the stream at a predictable cadence.
Throttle visual work with requestAnimationFrame
FRAME CADENCETime-based throttle is great when the interval is a product rule. Visual updates are different: the browser paints frames, not arbitrary 100ms windows. For pointer previews, canvas drawing, and scroll-linked transforms, schedule one requestAnimationFrame and keep replacing the saved arguments until the frame runs.
function rafThrottle(fn, raf = { requestAnimationFrame, cancelAnimationFrame }) { let frameId; let lastArgs; let lastThis; let result; function invoke() { const args = lastArgs; const receiver = lastThis; frameId = lastArgs = lastThis = undefined; result = fn.apply(receiver, args); return result; } function throttled(...args) { lastArgs = args; lastThis = this; if (frameId === undefined) frameId = raf.requestAnimationFrame(invoke); return result; } throttled.cancel = () => { if (frameId !== undefined) raf.cancelAnimationFrame(frameId); frameId = lastArgs = lastThis = undefined; }; throttled.flush = () => frameId === undefined ? result : (raf.cancelAnimationFrame(frameId), invoke()); return throttled;}Line 18 requests one frame. If more events arrive before that frame, lines 16 and 17 simply replace the saved arguments. Line 23 cancels a frame that has not run yet, which is important for component cleanup and Reset buttons.
Some performance fixes remove the handler entirely. position: sticky, CSS transitions, scroll-driven animations, and content-visibility often beat any JavaScript limiter. When you do listen to scroll or touch and never call preventDefault(), use a passive listener so the browser can keep scrolling.
Production patterns: libraries, cleanup, and requests
REAL APPSLodash is the battle-tested reference. Its docs specify leading, trailing, maxWait for debounce, last-argument invocation, and .cancel() and .flush(). Lodash's throttle implementation delegates to debounce with maxWait set to the wait interval, which is a useful way to understand their shared behavior.
| Concern | What to do | Why it matters |
|---|---|---|
| Lodash reference | Battle-tested _.debounce and _.throttle support leading, trailing, .cancel(), and .flush(). | Lodash documents that both wrappers invoke with the last arguments; its throttle source delegates to debounce with maxWait set to wait. |
| Passive listeners | Use { passive: true } for scroll and touch listeners that never call preventDefault(). | The browser can scroll without waiting for your handler. |
| Abort stale requests | Debouncing a promise-returning search does not automatically cancel older network responses. | Pair the wrapper with AbortController or ignore responses that are no longer current. |
| Unmount cleanup | Call .cancel() and abort work when a component or page goes away. | Prevents memory leaks and stale UI updates. |
let currentRequest;const searchLater = debounce(async (term) => { currentRequest?.abort(); currentRequest = new AbortController(); const response = await fetch("/api/search?q=" + encodeURIComponent(term), { signal: currentRequest.signal, }); renderResults(await response.json());}, 250); input.addEventListener("input", (event) => searchLater(event.target.value));window.addEventListener("beforeunload", () => { searchLater.cancel(); currentRequest?.abort();});Debouncing a promise-returning function only controls when it starts. It does not make older responses arrive first. Abort the previous request, or keep a request id and ignore stale responses. The cancellation lesson goes deeper on AbortController, and the memory management lesson explains why cleanup on unmount matters.
Common bugs and misconceptions
DEBUGGING- Creating the wrapper inside the event handler. The timer is stored in the wrapper, so a new wrapper means a new timer that cannot cancel the previous call.
- Dropping
this. Methods needfn.apply(thisArg, args), not a barefn(...args)with the wrong receiver. - Using stale arguments. Trailing work should use the latest event data, not the arguments from the first call in the burst.
- Forgetting cleanup. Pending timers, animation frames, and fetches can update a component after it unmounts unless you cancel them.
- Assuming debounce orders promises. Debounce delays starts; it does not guarantee network responses arrive in the same order.
// Bug: this creates a new wrapper for every event.input.addEventListener("input", (event) => { debounce(() => search(event.target.value), 300)();}); // Fix: create one wrapper and reuse it.const debouncedSearch = debounce((value) => search(value), 300);input.addEventListener("input", (event) => { debouncedSearch(event.target.value);});| Option | Timeline behavior | Typical use |
|---|---|---|
| Trailing debounce | No immediate call; one call after quiet time. | Default for search and autosave so only the final value is sent. |
| Leading debounce | Immediate call; optional trailing call if more calls arrive. | Useful for instant feedback plus a final sync. |
| Leading throttle | Run the first call in an interval. | Good when the UI must react immediately. |
| Trailing throttle | Run the last saved call at the interval boundary. | Good when the final position matters. |
maxWait | Forces a debounced call during continuous input. | Prevents an autosave or sync from waiting forever. |
Choose the right limiter
SORTERChoose by the user-visible cadence. Quiet final value means debounce. Regular samples during a stream means throttle. Pixel updates mean rAF throttle. If CSS can do the job, remove JavaScript from the hot path.
- Send a search request after the user stops typing
- Recompute a chart after window resizing settles
- Record scroll depth at a steady cadence
- Limit drag handler work while still updating during the drag
- Move a visual pointer preview with the latest coordinates
- Update a transform based on scroll position
Keep a header stuck to the top while scrollingSkip rendering work for content below the fold
Sort each scenario by the limiter that best fits the user-visible cadence.
Practice exercises
5 TASKSPredict the output from a trailing debounce timeline.
const clock = createFakeClock();
const log = [];
const search = debounce((value) => log.push(clock.now() + "ms " + value), 100, {}, clock);
search("a");
clock.tick(40);
search("ab");
clock.tick(40);
search("abc");
clock.tick(100);
console.log(log.join(" | "));The first two timers are cleared. The third call survives, so the only output is 180ms abc.
Predict which values fire when both edges are enabled.
const clock = createFakeClock();
const log = [];
const save = debounce((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);
save("a");
clock.tick(40);
save("ab");
clock.tick(100);
console.log(log.join(" | "));The output is 0ms a | 140ms ab: first at the leading edge, then 100ms after the second call.
Predict the throttled output through the first interval boundary.
const clock = createFakeClock();
const log = [];
const track = throttle((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);
track("top");
clock.tick(20);
track("middle");
clock.tick(50);
track("bottom");
clock.tick(30);
console.log(log.join(" | "));By 100ms the output is 0ms top | 100ms bottom. The trailing edge uses the latest saved position.
Which method should cleanup call to drop a pending debounced save?
function saveDraft() {
console.log("saved");
}
const debounced = debounce(saveDraft, 500);
function cleanup() {
// stop the pending save when the component unmounts
}function saveDraft() {
console.log("saved");
}
const debounced = debounce(saveDraft, 500);
function cleanup() {
debounced.cancel();
}.cancel() clears the pending timer and forgets saved arguments, so the unmounted component is not updated later.
Predict the output when maxWait forces a debounced call.
const clock = createFakeClock();
const log = [];
const sync = debounce((value) => log.push(clock.now() + "ms " + value), 100, { maxWait: 250 }, clock);
sync("a");
clock.tick(80);
sync("b");
clock.tick(80);
sync("c");
clock.tick(80);
sync("d");
clock.tick(10);
console.log(log.join(" | "));The forced output is 250ms d. maxWait invokes with the latest value even though the stream never paused for 100ms.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best defines debounce?
Choose an answer to see the explanation.
Question 2 of 7Which sentence best defines throttle?
Choose an answer to see the explanation.
Question 3 of 7What does the trailing debounce timeline print?
Read the code, then predictconst clock = createFakeClock(); const log = []; const search = debounce((value) => log.push(clock.now() + "ms " + value), 100, {}, clock); search("a"); clock.tick(40); search("ab"); clock.tick(40); search("abc"); clock.tick(100); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 4 of 7With both
leadingandtrailingtrue, when does the trailing call happen?Read the code, then predictconst clock = createFakeClock(); const log = []; const save = debounce((value) => log.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock); save("a"); clock.tick(40); save("ab"); clock.tick(100); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 5 of 7What does
maxWaitadd to debounce?Read the code, then predictconst clock = createFakeClock(); const log = []; const sync = debounce((value) => log.push(clock.now() + "ms " + value), 100, { maxWait: 250 }, clock); sync("a"); clock.tick(80); sync("b"); clock.tick(80); sync("c"); clock.tick(80); sync("d"); clock.tick(10); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 6 of 7Which job is the best fit for requestAnimationFrame throttling?
Choose an answer to see the explanation.
Question 7 of 7What production cleanup belongs with a debounced search request?
Choose an answer to see the explanation.
Key takeaways
- Debounce waits for quiet and runs once with the latest arguments.
- Throttle runs at most once per interval and can keep a trailing latest value.
- Leading and trailing describe the start and end of a wait window;
maxWaitforces progress during continuous input. - rAF throttle is best for visual updates tied to rendering frames.
- Create wrappers once, preserve
thisand arguments, cancel on cleanup, and abort stale async work.
Next, the Performance stage moves into DOM and rendering performance: layout, paint, batching reads and writes, and avoiding unnecessary rendering work.