cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Debounce & throttle

Learn debounce, throttle, leading and trailing calls, maxWait, requestAnimationFrame throttling, and cancellation for noisy JavaScript events.

By the end, you can
  • 01
    Limit noisy handlersChoose debounce, throttle, or requestAnimationFrame throttling for input, scroll, resize, mouse, and pointer events.
  • 02
    Implement robust wrappersPreserve this and arguments, support leading and trailing edges, add maxWait, and expose .cancel() and .flush().
  • 03
    Avoid 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.

Plain definition

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.

Debounce, throttle, rAF throttle, and CSS alternatives
ToolCadenceUse it for
DebounceRuns after calls stop for wait ms, using the latest arguments.Search-as-you-type, autosave, resize end, validation after typing.
ThrottleRuns at most once per interval, optionally at the leading and trailing edges.Scroll position tracking, drag handlers, rate-limited buttons, analytics pings.
rAF throttleRuns at most once per rendered frame with the latest arguments.Pointer previews, scroll-linked visual updates, canvas or DOM transforms.
CSS alternativeAvoids 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 COUNTS

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

Noisy event limiter playground
Event limiter wiringJavaScript
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}
Move the pointer here, or focus and press Enter.
Live countslatest 18 events
Raw
0
Debounced
0
Throttled
0
rAF
0
  1. Events will appear here.
Try it yourself

Type in the input or move inside the pointer pad. The same raw event feeds all three wrappers.

This playground uses the lesson's real debounce, throttle, and rAF-throttle wrappers. Reset cancels pending work before clearing the counts.

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 THROUGH

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

Real-life analogyElevator doors and a bus schedule

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.

Production debounce with leading, trailing, maxWait, cancel, and flushPop out in the code editor (opens in a new tab)JavaScript
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.

Debounce timeline lab
Step 0 of 10Ready
Your turn: follow the blue line

Trailing debounce waits until calls stop, then invokes once with the latest arguments.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" | "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the debounce edge behavior

The source changes to keep the timeline small. Reset returns to the trailing-only case that fires once at 180ms.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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

INTERVALS

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

Production throttle with leading, trailing, cancel, and flushPop out in the code editor (opens in a new tab)JavaScript
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 timeline lab
Step 0 of 13Ready
Your turn: follow the blue line

Throttle samples a noisy stream at a steady cadence. Leading and trailing decide which edges of each interval can run.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" | "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the throttle edge behavior

Watch the interval boundary. Leading controls the first call; trailing controls the latest saved call at the boundary.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 CADENCE

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

rAF throttle for visual updatesPop out in the code editor (opens in a new tab)JavaScript
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.

Use CSS when JavaScript is not needed

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 APPS

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

Production details that matter
ConcernWhat to doWhy it matters
Lodash referenceBattle-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 listenersUse { passive: true } for scroll and touch listeners that never call preventDefault().The browser can scroll without waiting for your handler.
Abort stale requestsDebouncing 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 cleanupCall .cancel() and abort work when a component or page goes away.Prevents memory leaks and stale UI updates.
Debounced search with AbortController cleanupJavaScript
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 need fn.apply(thisArg, args), not a bare fn(...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 and fix: create the debounced function onceJavaScript
// 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);});
Leading, trailing, and maxWait quick reference
OptionTimeline behaviorTypical use
Trailing debounceNo immediate call; one call after quiet time.Default for search and autosave so only the final value is sent.
Leading debounceImmediate call; optional trailing call if more calls arrive.Useful for instant feedback plus a final sync.
Leading throttleRun the first call in an interval.Good when the UI must react immediately.
Trailing throttleRun the last saved call at the interval boundary.Good when the final position matters.
maxWaitForces a debounced call during continuous input.Prevents an autosave or sync from waiting forever.

Choose the right limiter

SORTER

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

Debounce, throttle, rAF throttle, or no JavaScript?
  • 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 scrolling
  • Skip rendering work for content below the fold
Try it yourself
0 of 8 correct

Sort each scenario by the limiter that best fits the user-visible cadence.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Practice exercises

5 TASKS
Exercise 1 · Warm-upPredict a trailing debounce

Predict the output from a trailing debounce timeline.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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(" | "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeLeading plus trailing

    Predict which values fire when both edges are enabled.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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(" | "));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThrottle the scroll position

      Predict the throttled output through the first interval boundary.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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(" | "));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAdd cleanup

        Which method should cleanup call to drop a pending debounced save?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function saveDraft() {
          console.log("saved");
        }
        const debounced = debounce(saveDraft, 500);
        
        function cleanup() {
          // stop the pending save when the component unmounts
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeUse maxWait during continuous input

          Predict the output when maxWait forces a debounced call.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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(" | "));

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Debounce & throttle quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence best defines debounce?

              Choose an answer to see the explanation.

            2. Question 2 of 7Which sentence best defines throttle?

              Choose an answer to see the explanation.

            3. Question 3 of 7What does the trailing debounce timeline print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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(" | "));

              Choose an answer to see the explanation.

            4. Question 4 of 7With both leading and trailing true, when does the trailing call happen?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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(" | "));

              Choose an answer to see the explanation.

            5. Question 5 of 7What does maxWait add to debounce?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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(" | "));

              Choose an answer to see the explanation.

            6. Question 6 of 7Which job is the best fit for requestAnimationFrame throttling?

              Choose an answer to see the explanation.

            7. 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; maxWait forces progress during continuous input.
            • rAF throttle is best for visual updates tied to rendering frames.
            • Create wrappers once, preserve this and 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.

            CompleteFrontend Clear concepts. Working examples.