completefrontend
Open lab My progress
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 events that fire oftenPick debounce, throttle, or requestAnimationFrame throttle for input, scroll, resize, mouse, and pointer events.
  • 02
    Build full wrappersKeep this and the arguments, run leading and trailing calls, add maxWait, and offer .cancel() and .flush().
  • 03
    Avoid bugs in real appsMake each wrapper once, clean up when a component is removed, stop old requests, and test timelines with a fake clock.

Limit noisy event work

Some browser events fire very often. An input event fires on every key press. scroll, resize, mousemove, and pointermove can fire many times per second. Often the user only sees one real change at the end.

Some work is slow or costly. Examples are sending a request to a server, measuring the page, a big calculation, or drawing part of the page again. If that work runs on every event, the browser gets busy and the page feels slow.

Plain definition

A wrapper is a new function that you call instead of the real one. The wrapper decides when the real function runs. This lesson builds three wrappers:

  • Debounce waits until the calls stop for wait milliseconds. Then it runs the real function once, with the arguments from the last call.
  • Throttle runs the real function at most once per time period, such as once every 100 milliseconds, while the calls keep coming.
  • requestAnimationFrame throttle (rAF throttle for short) runs it at most once each time the browser draws the page. Each drawing is called a frame.

The table compares these three wrappers, plus a fourth choice: using CSS instead of JavaScript. Each row says how often the work runs and where it fits.

Four ways to limit noisy work
ToolHow often the work runsGood for
DebounceOnce, after the calls stop for wait ms. It uses the arguments from the last call.A search box, autosave, work after resizing ends, checking a field after typing.
ThrottleAt most once per interval, at the start or the end of the interval.Tracking the scroll position, drag handlers, a button that acts once per second, analytics.
rAF throttleAt most once per frame, with the newest arguments.A preview that follows the pointer, moving things as the page scrolls, drawing on a canvas.
CSS insteadNo JavaScript runs. The browser does the work itself.Scroll-driven animations, position: sticky, content-visibility, CSS transitions.

The timers lesson showed how to cancel a timer. The classic utilities lesson built tiny versions of debounce and throttle. This lesson builds full versions, the kind a real app uses. They add:

  • leading and trailing calls: running at the start or at the end of a wait,
  • maxWait: the longest the function may wait,
  • keeping this and the arguments,
  • cancel() and flush(), to throw away or finish waiting work,
  • tests with a fake clock, so every run gives the same result.

Why noisy events need a limit

LIVE COUNTS

You can still listen to every event. Noticing an event is cheap. The costly work inside the handler is what needs a limit: network requests, changes to the page, and updates to your app's data. That work only needs to run as often as the user can notice.

Count events and runs
How the playground uses the wrappersJavaScript
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 box or move the pointer inside the pad. Each event goes to all three wrappers.

This playground uses the lesson's real debounce, throttle, and rAF throttle wrappers. Reset cancels any waiting runs, then clears the counts.

Type quickly in the box, then stop. The Raw count goes up on every key press. The Debounced count waits until you stop. Now move the pointer inside the pad. The rAF count goes up at most once per frame, and it always uses the newest pointer position. The main thread lesson explains why each change to the page should be small and planned like this.

Debounce waits for quiet

STEP THROUGH

Use debounce when only the final value matters, not the values in between. For example:

  • a search box that searches for the finished word, not for every letter,
  • autosave after the user stops typing,
  • checking a form field after the user pauses,
  • work that should run once the user stops resizing the window.
Real-life analogyElevator doors and debounce

Picture elevator doors that open again each time someone steps in. The elevator only leaves when nobody has come in for a few seconds. Then it leaves once, with everyone inside. Debounce works the same way: each new call starts the quiet wait again.

In real life: Each new person makes the doors wait again
In JavaScript: Each new call starts the wait again
In real life: The elevator leaves once, after the last person steps in
In JavaScript: The function runs once, with the last call's arguments

The examples in this lesson use a fake clock, made by createFakeClock(). It works like real timers, but time only moves when you call clock.tick(ms). So every run prints the same thing. Each wrapper takes the clock as its last argument.

A search box runs only the last searchPop out in the code editor (opens in a new tab)JavaScript
const clock = createFakeClock();
const search = debounce((term) => console.log("search:", term), 100, {}, clock);

search("c");
search("ca");
search("cat");
clock.tick(100);

Line 2 makes one debounced wrapper called search, with a wait of 100 milliseconds. Lines 4 to 6 call it three times in a row. Each call replaces the saved search word. Line 7 moves the fake clock forward 100 milliseconds. The wait ends, and the real function runs once. It prints search: cat. Only the last word reaches the function.

Three debounce rules
  • The wait starts again after each call. It does not count from the first call.
  • A leading call runs right away, at the start of a wait. A trailing call runs at the end of the wait. With both turned on, the trailing call only runs if another call came in after the leading call.
  • maxWait is the longest the function may wait while calls keep coming. It does not make every call run.
The first call runs now, the last call runs laterPop out in the code editor (opens in a new tab)JavaScript
const clock = createFakeClock();
const fired = [];
const save = debounce((value) => fired.push(clock.now() + "ms " + value), 100, { leading: true, trailing: true }, clock);

save("draft");
clock.tick(40);
save("final");
clock.tick(100);
console.log(fired.join(" | "));

Line 3 turns on both leading and trailing. Line 5 calls save("draft"). Leading is on, so it runs right away, at 0ms. Line 7 calls again at 40ms, with final. That call came during the wait, so a trailing call runs 100ms later, at 140ms. Line 9 prints 0ms draft | 140ms final.

Here is the full debounce. Its first two arguments are the real function, fn, and the wait in milliseconds. The last argument, clock, lets the tests pass in the fake clock. In a browser it uses the real Date.now, setTimeout, and clearTimeout.

A full 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;}

Here is what the main lines do:

  • Lines 2 and 3 read the options. leading is off unless you turn it on. trailing is on unless you turn it off.
  • Lines 8 to 14 are invoke(), which runs the real function. Line 12 uses fn.apply(receiver, args). apply calls a function with a chosen this and a list of arguments. So a method still gets its own object as this, and the function gets the newest arguments.
  • Lines 35 and 36 save the newest arguments and this on every call.
  • Line 37 cancels the old timer, and line 38 starts a new one. This is the "wait starts again" rule.
  • Line 39 starts a second timer for maxWait. It starts only once per burst, which is a group of calls close together. This timer makes sure the function still runs while someone types without stopping.
  • Lines 43 and 48 add cancel() and flush(). The section on real apps shows how to use them.
Step through a debounce timeline
Step 0 of 10Ready
Your turn: follow the blue line

Trailing debounce waits until the calls stop, then runs once with the newest arguments.

Running in
  1. script
Next: line 1
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(" | "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose when debounce may run

The code changes a little for each choice, to keep the timeline short. Reset goes back to trailing only, which runs 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 first timeline, calls come at 0, 40, and 80 milliseconds. Later calls cancel the first two timers. The last timer is never canceled, so it ends at 180 milliseconds, and only abc runs. Pick the other choices to see leading calls and maxWait.

Debounce with a 100 millisecond wait. Calls with a at 0ms, ab at 40ms, and abc at 80ms. Each call restarts the wait. After 100ms of quiet, the function runs once at 180ms with abc.aababc100ms of quietabcstarts waitrestartsrestartsruns once0ms40ms80ms180ms
Each call starts the 100ms wait again. The wait only finishes after the last call, so the function runs once, at 180ms, with abc.

Throttle runs at a steady pace

INTERVALS

Use throttle when work should keep happening while events keep coming, just not on every event. For example:

  • a bar that shows how far down the page you have scrolled,
  • a handler that moves something while you drag it,
  • a button that may only act once per second,
  • analytics that record what the user does, now and then.

The time period throttle waits between runs is called the interval. In the examples below it is 100 milliseconds.

A full 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 remembers when the real function last ran, in lastInvokeTime (line 4). On each call:

  • Lines 25 and 26 save the newest arguments and this.
  • Line 31 works out how much of the interval is left.
  • If no time is left (line 32), the function runs now (line 35).
  • If some time is left, line 37 starts one timer for the time that is left. It does this only when trailing is on and no timer is waiting yet.

Later calls during the interval only replace the saved arguments. So when the timer ends, the function runs with the newest event data.

A scroll handler records some positions, not allPop out in the code editor (opens in a new tab)JavaScript
const clock = createFakeClock();
const positions = [];
const trackScroll = throttle((position) => positions.push(position), 100, {}, clock);

trackScroll(10);
clock.tick(20);
trackScroll(80);
clock.tick(80);
console.log(positions.join(" -> "));

Line 5 sends position 10. It is the first call, so it runs right away. Line 7 sends 80 at 20ms. That is inside the 100ms interval, so it waits. Line 8 moves the clock to 100ms. The interval ends, and the saved 80 runs. Line 9 prints 10 -> 80.

Choosing leading and trailing for throttle
  • Set leading: false when even the first event should wait for a full interval.
  • Set trailing: false when a value that came in during the interval is not worth running later.
  • Make the wrapper once and keep it. The interval is stored in the wrapper, not in the event listener.
Step through a throttle timeline
Step 0 of 13Ready
Your turn: follow the blue line

Throttle runs at a steady pace while calls keep coming. Leading and trailing decide whether it runs at the start or the end of each interval.

Running in
  1. script
Next: line 1
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(" | "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose when throttle may run

Watch where each 100ms interval ends. Leading decides if the first call runs right away. Trailing decides if the newest saved call runs when the interval ends.

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.

See how this differs from debounce. While the calls keep coming, the throttled function runs at 0ms, 100ms, and 200ms. It does not wait for the calls to stop. It runs at a steady pace.

Throttle with a 100 millisecond interval. Calls: top at 0ms, middle at 20ms, bottom at 70ms, after at 120ms. Runs: top at 0ms, bottom at 100ms, after at 200ms. Middle is replaced by bottom.calls0ms20ms70ms120mstopmiddlebottomafterreplacedrunstopbottomafter0ms100ms200ms
The function runs at most once every 100ms. Calls in between are saved, and a newer call replaces an older one, so bottom runs at 100ms and middle never runs.

Throttle drawing work with requestAnimationFrame

ONCE PER FRAME

A time throttle works well when the interval is a rule you choose, like "at most once per second". Work that changes what the user sees is different. The browser draws the page in frames, often about 60 times per second. It does not draw on a 100ms schedule.

requestAnimationFrame(callback) asks the browser to run callback just before it draws the next frame. rAF throttle uses it like this:

  • On the first call, ask the browser for one frame.
  • On later calls before that frame, only replace the saved arguments.
  • When the frame comes, run the real function once, with the newest arguments.

Use it for a preview that follows the pointer, for drawing on a canvas, or for moving things as the page scrolls.

rAF throttle for drawing workPop 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 asks for one frame, but only if no frame is waiting yet. Lines 16 and 17 save the newest arguments on every call. Line 23 cancels a frame that has not run yet, with cancelAnimationFrame. That matters when a component is removed from the page or a Reset button clears everything.

The next example passes in a fake frame clock, frameClock. Instead of waiting for the browser, it saves each frame callback in the frames list.

A resize preview uses the latest widthPop out in the code editor (opens in a new tab)JavaScript
const frames = [];
const frameClock = {
  requestAnimationFrame(callback) {
    frames.push(callback);
    return frames.length;
  },
  cancelAnimationFrame() {},
};
const panel = { width: 0 };
const resizePanel = rafThrottle((width) => {
  panel.width = width;
}, frameClock);

resizePanel(800);
resizePanel(900);
frames.shift()();
console.log(panel.width);

Lines 14 and 15 send two widths, 800 and 900, before any frame runs. Only one frame was asked for. Line 16 runs that frame by hand. It uses the latest width, so line 17 prints 900. In a real browser, the frame runs just before the next drawing.

Two calls, resizePanel(800) and resizePanel(900), arrive before the next animation frame. 800 is replaced. The frame runs once and sets panel.width to 900.calls800replaced900latestnext framepanel.width = 900runs once
Two widths arrive before the browser draws its next frame. The frame runs once, with the latest width, 900.
When rAF is the wrong choice
  • For analytics or limits on network requests, use a time throttle. A frame is not a fixed number of milliseconds.
  • If resize work only matters after the window stops changing size, use debounce.
  • Browsers may pause frames in hidden tabs. So don't use rAF for work that must always happen.
Use CSS when you can

Sometimes the best fix is to need no handler at all. CSS can do some jobs by itself:

  • position: sticky keeps an element stuck in place while the page scrolls.
  • CSS transitions animate a change.
  • Scroll-driven animations move things as the page scrolls.
  • content-visibility lets the browser skip drawing parts that are off screen.

If you do listen to scroll or touch events and never call preventDefault(), add { passive: true } to the listener. A passive listener promises not to stop scrolling, so the browser can scroll without waiting for your code.

Real apps: libraries, cleanup, and requests

REAL APPS

Many apps use Lodash, a popular library of helper functions, instead of writing their own. Its _.debounce and _.throttle work like the versions in this lesson:

  • They have leading and trailing options. Debounce also has maxWait.
  • The real function gets the arguments from the last call.
  • The wrapper has .cancel() and .flush().

Lodash even builds its throttle out of its debounce, with maxWait set to the same time as the wait. That shows how close the two ideas are.

Each row of the next table is one thing to get right in a real app: what to do, and why it matters.

Details that matter in real apps
TopicWhat to doWhy it matters
LodashLodash is a popular helper library. Its _.debounce and _.throttle have leading, trailing, .cancel(), and .flush().Both run with the arguments from the last call. Its throttle is built from its debounce, with maxWait set to wait.
Passive listenersAdd { passive: true } to scroll and touch listeners that never call preventDefault().The browser can scroll without waiting for your handler.
Stop old requestsDebouncing a search does not stop older requests that already started.Use AbortController to stop them, or ignore answers that are out of date.
Clean upCall .cancel() and stop requests when a component or page goes away.Waiting work can't change a page that is gone, and it doesn't hold on to memory.

A search box often sends a request with fetch. An AbortController can stop a request that has already started. You pass its signal to fetch, and later call abort() on the controller.

A debounced search that stops old requestsJavaScript
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();});
  • Line 2 makes a debounced search that waits 250 milliseconds.
  • Line 3 stops the previous request, if there is one.
  • Line 4 makes a new controller for this request, and line 6 links the request to it.
  • Lines 13 and 14 run when the page is closing. Line 13 cancels a search that is still waiting. Line 14 stops a request that has already started.

The wrapper has two methods for waiting work. cancel() throws away the waiting call. flush() runs the waiting call right now, instead of waiting.

Cancel an old save, then flush the new onePop out in the code editor (opens in a new tab)JavaScript
const clock = createFakeClock();
const saved = [];
const save = debounce((value) => saved.push(value), 100, {}, clock);

save("old");
save.cancel();
clock.tick(100);
save("new");
save.flush();
console.log(saved.join(" | "));

Line 6 cancels the save of old, so it never runs. Line 7 moves the clock, and nothing happens. Line 8 saves new. Line 9 flushes it, so it runs right away. Line 10 prints new.

Debounce only controls when a request starts. It does not control the order the answers come back in. An older, slower request can still answer last. So stop the old request, or give each request a number and ignore answers to old numbers. The cancellation lesson covers AbortController in depth. The memory management lesson explains why to clean up when a component is removed from the page.

Common bugs and mix-ups

DEBUGGING
  • Making the wrapper inside the event handler. The timer is stored in the wrapper. A new wrapper has a new timer, so it can't cancel the timer from the last event.
  • Losing this. If the real function is a method, call it with fn.apply(thisArg, args). A plain fn(...args) loses the object the method belongs to.
  • Using old arguments. The trailing call should use the newest event data, not the arguments from the first call.
  • Forgetting cleanup. Waiting timers, frames, and requests can still change a component after it is removed from the page. Cancel them when it is removed.
  • Thinking debounce keeps answers in order. Debounce delays when requests start. Answers from the network can still come back in any order.
  • Calling flush() during cleanup. flush() runs the waiting call on purpose. Cleanup usually needs cancel() instead.
  • Thinking a passive listener is a throttle. A passive listener helps scrolling stay smooth. It does not make the handler run less often.
Bug and fix: make 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);});

Line 3 makes a new wrapper on every input event and calls it right away. Each wrapper has its own timer, so every key press still searches. Line 7 makes one wrapper. Line 9 calls that same wrapper on every event, so all the calls share one timer.

Left: a new debounce wrapper inside the listener. Three fast keystrokes make three wrappers with three timers, so search runs 3 times. Right: one wrapper made once. The same three keystrokes share one timer, so search runs once.new wrapper per eventtimertimertimer3 wrappers, 3 timersruns 3 timesone wrapper, made oncetimer1 wrapper, 1 timerruns once
The timer lives inside the wrapper. Three fast keystrokes with a new wrapper each make three timers, and nothing cancels them. One shared wrapper has one timer, so it can wait for the last keystroke.

The table below sums up each option. Read a row to see when the function runs and where the option helps.

Leading, trailing, and maxWait at a glance
OptionWhen the function runsWhere it helps
Trailing debounceNothing runs right away. It runs once after the calls stop.The usual choice for search and autosave, so only the final value is sent.
Leading debounceIt runs right away. It runs once more at the end only if more calls came in.Quick feedback at the start, plus a final update at the end.
Leading throttleThe first call in an interval runs right away.When the page must react at once.
Trailing throttleThe newest saved call runs when the interval ends.When the final position matters.
maxWaitDebounce runs even while the calls never stop.So autosave or syncing never waits forever.

Choose the right limiter

SORTER

Choose by how often the user needs to see a change:

  • Only the final value, after a pause? Use debounce.
  • Regular updates while events keep coming? Use throttle.
  • Changes to what is drawn on the screen? Use rAF throttle.
  • Can CSS do the job? Then don't use JavaScript for it at all.
Debounce, throttle, rAF throttle, or no JavaScript?
  • Send a search request after the user stops typing
  • Redraw a chart after the user stops resizing the window
  • Record how far the user scrolled, at a steady pace
  • Limit drag handler work while still updating during the drag
  • Move a preview that follows the pointer
  • Move an element based on the scroll position
  • Keep a header stuck to the top while scrolling
  • Skip drawing content that is far below the screen
Try it yourself
0 of 8 correct

Put each task under the tool that fits how often the user needs to see a change.

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

Calls come at 0, 40, and 80 milliseconds. Predict what the log prints.

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

    Both leading and trailing are on. Predict which values run, and when.

    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 what the throttled log holds when the clock reaches 100ms.

      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

        When the component is removed, the waiting save should not run. Which method should cleanup() call?

        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 waiting save when the component is removed
        }

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

          Exercise 5 · ChallengeUse maxWait while input never stops

          The calls never pause for 100ms. Predict what maxWait makes run, and when.

          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 describes debounce?

              Choose an answer to see the explanation.

            2. Question 2 of 7Which sentence best describes 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 fits requestAnimationFrame throttle best?

              Choose an answer to see the explanation.

            7. Question 7 of 7Which cleanup belongs with a debounced search request?

              Choose an answer to see the explanation.

            Key takeaways

            • Debounce waits until the calls stop, then runs once with the arguments from the last call.
            • Throttle runs at most once per interval. With trailing on, it also runs the newest saved call at the end of the interval.
            • A leading call runs at the start of a wait, and a trailing call runs at the end. maxWait makes debounce run even while calls never stop.
            • rAF throttle is best for work that changes what is drawn, because it runs once per frame.
            • Make each wrapper once, keep this and the arguments, cancel waiting work when you clean up, and stop old requests.

            Next, the Performance stage moves on to DOM and rendering performance. You will learn how the browser lays out and draws the page, and how to avoid work it doesn't need to do.

            CompleteFrontend Clear concepts. Working examples.