Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~23 min· topic 7 of 8

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, pointermove or resize.
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 with clearTimeout.
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.

app.jswhole filejavascript
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.

debounce.jswhole filejavascript
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.)

throttle.jswhole filejavascript
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.

app.jswhole filejavascript
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.

Compare them on a timelinediagram
Rendering diagram…

Try it yourself

  1. 1

    Type slower than the delay

    In the first example, change i * 60 to i * 400. Predict how many searches run now, and for which words. Then run it.

  2. 2

    Change the window

    In the second example, change the interval from 100 to 60. Predict the handled times before running.

  3. 3

    Make a new wrapper each time (on purpose)

    In the first example, change the listener to debounce(search, 300)(input.value); and delete debouncedSearch. Predict how many searches run, then explain why.

Code & diagrams

A debounced search box New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

input: m
input: ma
input: man
search #1 for "man" -> [ 'mango', 'mangosteen' ]
A throttle you can test with a fake clock New tab

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

Press Run to see the page.

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 handled
Raw, throttled and per-frame scroll handlers New tab

Scroll 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

Press Run to see the page.

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

terminal
$ Console
── what you'll see ──
search #1 for "m"
search #2 for "ma"
search #3 for "man"

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.

terminal
$ the page shows
── what you'll see ──
After reloading, the page restores a position a little above where you actually stopped.

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 runs fn with the latest args. That gives a run at the start, regular runs during, and one at the end. Lodash's throttle is actually its debounce with maxWait equal to the wait.

  • ▸

    Use this and arguments carefully: fn.apply(this, args) inside a regular function wrapper preserves the this the 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 a flush() 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. 1

    The problem: high-frequency events. Typing in a search box fires input for every key. Scrolling fires scroll many times a second, and so do pointermove, wheel and window resize. 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. 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 delay milliseconds does the function actually run, once, with the latest arguments. Typing "mango" quickly makes five input events 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. 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. 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 a timerId; 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. 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. 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: IntersectionObserver for "is this element visible?" and ResizeObserver for element size changes (Topic 11.6).

Explain it without notes

01

Explain debounce and throttle, and give one use case for each.

02

Implement a debounce(fn, delay) function and explain how it works.

03

Why is the debounced function usually created once, outside the event listener?

04

When would you use requestAnimationFrame instead of a time-based throttle?

Practice

01

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.

02

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 debounce and throttle from 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.