cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

Debounce and throttle

Two small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.

The problem: events arrive faster than work can

Picture a search box on a shop with 2 million products. Each time the user types a letter, the page asks the server for suggestions. A user types wireless headphones: 19 characters in about 2.5 seconds. That is 19 requests, and the user only ever cared about the last one or two.

Now multiply. 50,000 people search at the same moment at peak. With one request per keystroke the suggestion service sees about 380,000 requests per second instead of maybe 40,000. Most of those answers arrive after the user typed the next letter, so the browser throws them away. You paid for servers to compute results nobody saw.

The browser makes this worse for some events. A scroll event can fire on every frame while the page moves, which is 60 times a second on a normal screen and 120 on a fast one. resize fires many times while the user drags a window edge, and mousemove and pointermove fire many times a second while the pointer moves.

If each event runs heavy work, such as measuring layout or sending a network request, the page stutters.

Two tools, two questions

Debounce and throttle are wrappers around a function. You give them a function and a time, and they give you back a new function that you call as often as you like. The wrapper decides when the real function actually runs.

  • Debounce answers: "has the user stopped?". It waits until no new call has arrived for, say, 300 ms, then runs once with the latest arguments. A burst of 19 keystrokes becomes 1 call.
  • Throttle answers: "is it time again?". It runs at most once every, say, 100 ms while calls keep coming. A 3 second scroll becomes about 30 calls instead of 180, at a steady rhythm.
A bar chart of three bursts. Typing a 19 character query fires 19 input events; a 300 ms debounce leaves 1 request. Scrolling for 3 seconds at 60 frames a second fires about 180 scroll events; a 100 ms throttle leaves about 30 runs. Dragging a window edge for 2 seconds fires about 120 resize events; a 150 ms debounce leaves 1 run.typing a querydebounce 300 ms19 events1 requestscrolling for 3 sthrottle 100 ms180 eventsabout 30 runsdragging a resizedebounce 150 ms120 events1 run
The hatched bars are what the browser fires; the green bars are what your code actually runs. The helper doesn't make any single run cheaper, it just runs far fewer of them.

What they are not

Both tools reduce how often work runs. Neither one makes the work cheaper, and neither one fixes ordering. If two search requests are both sent, the slower one can still answer last and overwrite the newer result.

That problem, and its fix with AbortController, is the subject of the lesson on request races. In a real search box you use both: debounce to send fewer requests, and race handling to ignore stale ones.

They are also not a cure for slow handlers. If one scroll handler takes 40 ms, throttling it to every 100 ms still blocks the main thread for 40 ms each time, and the page still drops frames. Throttle the trigger, but also make the work small, or move it off the scroll path entirely with tools like IntersectionObserver.

Debounce: wait for a pause

Think of a lift door. It starts to close a few seconds after the last person walks in. If someone else steps in, the door opens again and the countdown starts over, so the lift only leaves once nobody has come in for a few seconds. Debounce works the same way: every new call restarts the countdown, and the function runs only when a countdown gets to finish.

In code, the countdown is a single setTimeout. The wrapper keeps the id of the current timer. Each call does three things: it clears the old timer, it saves the newest arguments, and it starts a new timer. When a timer fires, it runs the real function with the saved arguments.

A worked example with real times

A user types lamp into a search box, pauses to think, then adds desk. The wait is 300 ms. Here is what the wrapper does, keystroke by keystroke.

  1. At 0 ms the user types l. No timer is running. The wrapper saves l and starts a timer for 300 ms.
  2. At 120 ms, 210 ms, and 330 ms the user types a, m, p. Each keystroke clears the running timer before it can fire and starts a fresh one. The saved value is now lamp, and the timer will fire at 630 ms.
  3. From 330 ms to 630 ms nothing happens. The timer fires at 630 ms and the search for lamp runs. That is the first and only request so far.
  4. At 900 ms the user types a space and keeps typing desk until 1,350 ms. Each key restarts the timer again.
  5. At 1,650 ms, 300 ms after the last key, the search for lamp desk runs. Nine keystrokes became two requests.
A timeline of 2 seconds. Four keystrokes at 0, 120, 210, and 330 ms spell lamp, then five more from 900 to 1350 ms add a space and desk. Each keystroke starts a 300 ms timer and cancels the one before. The search runs twice: for lamp at 630 ms and for lamp desk at 1650 ms.keystrokeseach key restarts ittimer300 ms quiet300 ms quietsearch runslamplamp desk01 s2 s
Every keystroke throws away the running timer (dashed) and starts a new one. Only a timer that gets to finish (green) runs the search, so nine keystrokes become two searches, each with the latest text.

Notice two things in the timeline. First, the user waits at least 300 ms after their last key before anything happens: debounce adds delay on purpose. Second, the work runs with the latest arguments, not the first ones. A debounced search never searches for la if the user went on to type lamp.

Seen from the server's side, the saving is plain. The input still updates on every key, because the text field itself is never debounced. Only the derived work, the request, waits. The server sees two requests where it would have seen nine.

A sequence diagram with three columns: keyboard, search box, and API. At 0 ms the keys l, a, m, p go into the search box, which restarts its timer three times. At 630 ms the search box sends GET ?q=lamp and the API answers with 8 suggestions. At 900 ms five more keys arrive: space, d, e, s, k. At 1650 ms the search box sends GET ?q=lamp desk and the API answers with 5 suggestions.keyboardsearch boxAPI0 msl a m p (4 keys)timer restarts 3 times630 msGET ?q=lamp8 suggestions900 msspace d e s k (5 keys)1650 msGET ?q=lamp desk5 suggestions
The search box takes every key at once, so the text is never late. Only the request waits for a pause, so the API sees ?q=lamp and ?q=lamp desk and nothing in between.

How long to wait

The wait is a trade between cost and feel. Too short and you save little: a fast typist makes a key every 100 to 150 ms, so a 100 ms debounce fires between many of their keys. Too long and the page feels slow: the classic research on response times puts the limit for "instant" at about 100 ms and for an unbroken flow of thought at about 1 second.

UseTypical waitWhy
Search suggestions150 to 300 msLonger than the gap between keys, short enough to feel live
Form field validation300 to 500 msDon't shout "invalid email" while the user is still typing it
Autosave a draft1000 to 2000 msSaves are heavier; the user doesn't watch for them
Layout work after a window resize100 to 200 msRun once the drag ends, not at every pixel

These are rules of thumb, not standards: treat them as starting points and measure. A good check: watch the network panel while a colleague types normally. If most keystrokes still cause a request, the wait is too short. If the suggestions appear a beat after the user has stopped and looked up, it is too long.

Check your understanding · Exercise 1

A 300 ms trailing debounce wraps a search. The user types keys at 0, 100, 200, 600, and 700 ms, then stops. Which statements are true? Select all that apply.

Throttle: run at a steady rate

Think of a bus that leaves the stop every 10 minutes. People arrive whenever they like, but the bus does not leave once per person, and it does not wait for people to stop arriving either. It leaves on schedule with whoever is there. Throttle works the same way: while calls keep coming, the function runs at most once per interval.

The difference from debounce is what a new call does to the timer. In debounce, a new call restarts the countdown, so a steady stream of calls can push the run back forever.

In throttle, a new call never moves the timer. It only updates the saved arguments. The window ends on time, no matter how many calls arrived inside it.

A worked example: a scroll progress bar

An article page shows a reading progress bar at the top, and it also reports to analytics how far the reader has scrolled. The scroll handler reads scrollY, works out a percentage, and calls a function reportDepth. We throttle reportDepth to once every 400 ms.

The reader scrolls without stopping from 100 ms to 1,500 ms. A real scroll fires an event every frame, but to keep the numbers small, say one arrives every 100 ms.

  1. At 100 ms the first scroll arrives. No window is open, so reportDepth runs at once (the leading edge) and a 400 ms window opens.
  2. From 200 to 400 ms three more scrolls arrive. Each one only saves the newest depth.
  3. At 500 ms the window ends. A call is waiting, so reportDepth runs with the newest depth, and a new window opens.
  4. This repeats at 900 ms and 1,300 ms. The scroll at 1,500 ms is the last one.
  5. At 1,700 ms the last window ends with the 1,500 ms depth still waiting. It runs (the trailing edge). Then a window ends with nothing waiting, and the throttle goes back to idle.
A timeline of 2 seconds with a scroll event every 100 ms from 100 to 1500 ms. A 400 ms throttle runs at 100, 500, 900, and 1300 ms, and a last trailing run at 1700 ms with the position from 1500 ms. A 300 ms debounce of the same events would run only once, at 1800 ms, after the scrolling stops.scroll eventswindowsthrottle runsat most one run per 400 msdebounce runsonly once, at the end01 s2 s
Throttle gives steady updates while the events keep coming, and its trailing run makes sure the last position counts. Debounce waits for the stream to end, which is too late for anything the user watches while scrolling.

Fifteen scroll events became five runs, spread evenly through the scroll. Compare that with a debounce of the same events: it would run once, at the very end. For a progress bar, which the user watches while scrolling, that is useless. For a search box, which only matters once the user stops, it is exactly right.

Why a debounce can starve

Here is the trap that makes people reach for throttle. If calls arrive closer together than the debounce wait, the debounced function never runs until they stop. Wrap a pointermove handler in a 200 ms debounce, and a user who drags a slider for 5 seconds sees no update at all for 5 seconds. Events every 16 ms keep restarting a 200 ms timer.

Some libraries offer a middle ground called max wait: a debounce that waits for a pause, but never longer than a limit. Lodash's debounce(fn, 300, { maxWait: 1000 }) runs at least once a second during a long burst. In fact Lodash builds its throttle exactly this way: a debounce whose maxWait equals its wait, with both edges on.

QuestionDebounceThrottle
When does it run?After wait ms with no new callsAt most once per wait ms while calls continue
Does a new call move the timer?Yes, it restarts itNo, it only saves the latest arguments
Runs during a 5 second burst0 (trailing) or 1 (leading)About 5000 / wait
First reactionAfter the pause (trailing) or at once (leading)At once (leading) or after one window
Good forSearch, validation, autosave, resize endScroll effects, drag, progress, analytics, rate limits

Leading and trailing edges

A burst of calls has two edges: the first call, where the burst starts, and the moment it ends. Both helpers let you choose on which edge the function runs. These two switches, leading and trailing, explain most of the surprising behavior people see in real code.

  • Leading edge: run at once, on the first call of a burst. The user gets an instant reaction. The cost is that the run uses the first arguments, which may already be out of date a moment later.
  • Trailing edge: run once the timer ends, with the latest arguments. The result is always current. The cost is the delay: the user waits for the timer.
  • Both: run at once, and again at the end if more calls arrived in between. Instant and current, at the price of up to one extra run per burst.
  • Neither: the function never runs. Libraries allow it, and it is almost always a bug.

The defaults you get

Defaults differ, so read them before you rely on them. Lodash's debounce is trailing only by default. Its throttle is leading and trailing by default. Many hand-written debounce functions in blog posts are trailing only and many hand-written throttles are leading only, which silently drops the last event of a burst.

SettingDebounce doesThrottle doesTypical use
Trailing onlyOne run, wait ms after the burstFirst run after one window, then steadySearch box, autosave, resize end
Leading onlyOne run at the start; later calls ignored until quietRun at once, then drop calls until the window endsStop a double submit, a "like" button
Leading and trailingA run at the start and one at the endInstant, steady, and the last value always landsScroll position, drag, live preview
NeitherNever runsNever runsNone: a configuration bug

Try it below. The same 17 events, in three groups, go through the helper you pick. Turn off the trailing edge on a throttle and watch the last event of the final group disappear: the page would show a scroll position that is a few hundred pixels out of date, and it would stay that way until the user scrolls again.

Helper:
Wait:
Edges:
17 events in three groups over 3 seconds. A debounce with a 300 ms wait, leading off and trailing on, runs 3 times, at 850 ms, 1300 ms, 2420 ms.eventsruns01 s2 s3 s
Orange runs happen on an event, with no delay. Green runs happen when a timer ends, after the events go quiet or the interval is up. Watch the first and last event of each group.
EventsRunsFirst reactionLast event used?
173after 750 msyes

A leading debounce for clicks

A leading-only debounce is the classic guard against double submits. The first click on "Pay" runs at once, so the user gets instant feedback. Every later click is ignored until 1,000 ms pass with no clicks. It is a helpful extra layer, but not a guarantee: a user who clicks twice 1.5 seconds apart still sends two payments.

For anything that costs money, the real fix belongs elsewhere. Disable the button while the request is in flight, and send an idempotency key (a unique id for each payment attempt) so the server can drop a duplicate. Debounce makes the page feel calmer; it is not a correctness tool.

Check your understanding · Exercise 2

A map shows the user's selected area and redraws a heat layer as they drag a selection box. The layer must react at once when the drag starts, update while dragging, and show the exact final box. Which setups work? Select all that apply.

Implementing them yourself

Interviewers love to ask for these two functions, and writing them is the best way to really understand them. Both fit in about 25 lines. The versions below are written in TypeScript, keep the latest arguments, and come with the extra controls real code needs. They were checked with fake timers against every timeline in this lesson.

Debounce with cancel and flush

ts
export function debounce<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let lastArgs: A | undefined;

  function run() {
    const args = lastArgs as A;
    timer = undefined;
    lastArgs = undefined;
    fn(...args);
  }

  function debounced(...args: A) {
    lastArgs = args;
    clearTimeout(timer); // safe even when timer is undefined
    timer = setTimeout(run, wait);
  }
  debounced.cancel = () => {
    clearTimeout(timer);
    timer = undefined;
    lastArgs = undefined;
  };
  debounced.flush = () => {
    if (timer === undefined) return; // nothing is waiting
    clearTimeout(timer);
    run();
  };
  return debounced;
}
A trailing debounce. cancel drops the waiting call; flush runs it now.

Walk through the parts. timer holds the one countdown that can be running. lastArgs holds the newest arguments, so the eventual run always uses them. run clears both before calling fn, so if fn itself calls the debounced function again, it starts a clean new cycle instead of seeing stale state.

  • cancel throws the waiting call away. Use it when the call no longer makes sense: the component unmounts, the user clears the search box, or the route changes. Forgetting it is how a search request fires after the user has already left the page.
  • flush runs the waiting call right now. Use it when you need the result before moving on: the user presses Enter, submits the form, or leaves the page with an unsaved draft. Without it, pressing Enter in a debounced search box waits out the timer for no reason.
  • The return value is dropped. A debounced function cannot return the result of fn, because fn has not run yet when it returns. If you need the result, have fn set state or resolve a promise.
A state machine with two states. Idle: no timer and no saved arguments. Waiting: a timer is running and the latest arguments are saved. A call in idle starts the timer and moves to waiting. A call in waiting restarts the timer and stays in waiting. From waiting, three things lead back to idle: the timer ends and the function runs, flush runs the function now, or cancel drops the arguments without running.idleno timerwaitingtimer + latest argscall: restart timercall: start timertimer ends: run fnflush: run fn nowcancel: drop args, no run
Only two states, and only the waiting state has anything to flush or cancel. That is why flush on an idle debounce must do nothing, and why run clears the state before it calls the function.

Throttle with leading and trailing edges

ts
type Edges = { leading?: boolean; trailing?: boolean };

export function throttle<A extends unknown[]>(fn: (...args: A) => void, wait: number, { leading = true, trailing = true }: Edges = {}) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let pending: A | undefined;

  function windowEnds() {
    if (trailing && pending) {
      const args = pending;
      pending = undefined;
      fn(...args);
      timer = setTimeout(windowEnds, wait); // a trailing run opens a new window
    } else {
      timer = undefined; // quiet window: go back to idle
      pending = undefined;
    }
  }

  return function throttled(...args: A) {
    if (timer === undefined) {
      if (leading) fn(...args);
      else pending = args;
      timer = setTimeout(windowEnds, wait);
    } else {
      pending = args;
    }
  };
}
A throttle. A call during an open window only saves its arguments.

The key line is the comment "a trailing run opens a new window". Without it, a trailing run at 500 ms could be followed by a leading run at 510 ms, two runs 10 ms apart, which breaks the promise of at most one run per wait. With it, the gap between any two runs is always at least wait.

The quiet branch matters too. If a window ends and nothing arrived during it, the throttle goes back to idle, so the next call runs at once on the leading edge. A single isolated call therefore runs exactly once, never twice. A cancel method, like the debounce one, clears timer and pending; add it the same way.

Details that bite in interviews

  • Old-style this. If the wrapped function is an object method that uses this, save this in the wrapper and call fn.apply(savedThis, args). With arrow functions and plain functions, as in modern code, you don't need it.
  • Timestamps versus timers. A throttle can also compare Date.now() with the last run time instead of using a timer. That version is shorter, but without a timer it has no trailing edge, so it drops the last call.
  • One wrapper, many calls. The state lives in the closure. Calling debounce(fn, 300) again creates a new, empty wrapper. Create it once and keep it, or nothing is ever debounced.
  • Testing. Real timers make tests slow and flaky. Use fake timers (vi.useFakeTimers() in Vitest, jest.useFakeTimers() in Jest, mock.timers in node:test) and move the clock by hand.

Check your understanding · Exercise 3

A teammate writes a throttle that runs fn when Date.now() - lastRun >= wait and otherwise ignores the call. What happens with a 200 ms wait when the user scrolls from 0 to 1,050 ms with events every 50 ms? Select all that apply.

Throttling to the frame with requestAnimationFrame

For work that changes what is on screen, the right interval is not a number of milliseconds you pick. It is the screen's own rhythm. A 60 Hz display shows a new frame about every 16.7 ms; a 120 Hz display every 8.3 ms. Any visual update that runs twice between two frames is wasted, because the user only ever sees the last one.

requestAnimationFrame(callback), often shortened to rAF, asks the browser to run callback once, just before it computes styles, layout, and paint for the next frame. A frame throttle uses it as the timer: the first event of a frame schedules one callback, later events in the same frame only update the saved values, and the callback runs once with the newest values.

Three frames of about 16.7 milliseconds each on a 60 Hz screen. Eight events that are not tied to frames, such as messages from a live data feed, arrive at different moments: three in the first frame, two in the second, three in the third. Without a limit, the handler runs eight times. With a requestAnimationFrame throttle, it runs three times, once per frame, just before each paint, using the newest event.frame 1frame 2frame 3eventsevery eventonce a framepaintpaintpaint0 ms
Without a limit, the handler runs for every event, and only the last result in each frame is ever painted, so the other hatched runs are wasted. With requestAnimationFrame, the work runs once per frame, right before paint, with the newest values.

The code

ts
export function rafThrottle<A extends unknown[]>(fn: (...args: A) => void) {
  let frame = 0; // rAF ids start at 1, so 0 means "nothing scheduled"
  let lastArgs: A | undefined;

  function throttled(...args: A) {
    lastArgs = args;
    if (frame !== 0) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      fn(...(lastArgs as A));
    });
  }
  throttled.cancel = () => {
    cancelAnimationFrame(frame);
    frame = 0;
  };
  return throttled;
}

const moveTooltip = rafThrottle((x: number, y: number) => {
  tooltip.style.transform = `translate(${x}px, ${y}px)`;
});
window.addEventListener("pointermove", (event) => moveTooltip(event.clientX, event.clientY));
At most one run per frame, always with the newest arguments.

This version has no wait to tune. It adapts to the display: at most 60 runs a second on a 60 Hz phone, 120 on a 120 Hz laptop, and fewer if the page is busy and frames are slow. It also has a trailing edge for free: the last event always lands in the next frame.

Why not just throttle to 16 ms?

A setTimeout throttle of 16 ms looks the same on paper, but timers are not tied to frames. A timer can fire right after a frame was painted, so its update waits almost a full frame to show. Two timers can land inside one frame, so one update is thrown away. On a 120 Hz screen, 16 ms is too slow and the motion looks choppy. rAF fixes all three because the browser itself decides when the callback runs.

A frame throttle also gives you a clean place to separate reads from writes. Read layout values such as getBoundingClientRect() or scrollY in the event handler or at the very start of the callback, then do all the writes (style, classList) together. Mixing reads after writes in a loop forces the browser to recompute layout again and again, which is called layout thrashing.

What the browser already does for you

  • scroll and resize are already frame-aligned. The HTML standard fires them during the "update the rendering" steps, so they arrive at most once per frame. Wrapping them in a frame throttle does not reduce the event count; it still helps by moving your work into the rAF callback, next to the paint.
  • Pointer events can be coalesced. Since Chrome 60, Chromium sends pointermove and mousemove at most once per frame, just before rAF callbacks, and keeps the in-between points; event.getCoalescedEvents() returns them. So in Chromium a frame throttle on pointermove mostly moves the work rather than cutting calls. A drawing app needs the coalesced points for a smooth line even when it renders once per frame.
  • rAF pauses in background tabs. Callbacks don't run while the tab is hidden. That saves battery for visual work, but it means a frame throttle is the wrong tool for anything that must happen, such as saving or analytics: the call would sit there until the user comes back.
  • Passive listeners. Adding { passive: true } to touchstart, touchmove, and wheel listeners tells the browser you won't call preventDefault(), so it can scroll at once without waiting for your handler. It is a separate fix from throttling, and you usually want both.

Check your understanding · Exercise 4

Which of these jobs fit a requestAnimationFrame throttle? Select all that apply.

Which one, where: search, resize, scroll, analytics

Most choices come down to two questions. Does the user watch the result while the events are still coming? And is the work visual, or does it cost a request or a save? The flowchart turns those into an answer, and the rest of this chapter walks through the four cases you will meet most.

A decision tree. First question: does the user watch it change while the events continue? If yes, ask whether the result is pixels: yes leads to a requestAnimationFrame throttle for drag and scroll effects, no leads to a time throttle for live data and map tiles. If the user doesn't watch it, ask whether only the final value is needed: yes leads to a debounce for search, saves, and resize, no leads to a throttle with a flush for analytics and logs.user watches it change?is the result pixels?need only the final value?yesnoyesnoyesnorAF throttledrag, scroll fxthrottlelive data, tilesdebouncesearch, savethrottleanalytics, flush
Two questions cover most cases. Before you add any of these, check whether an observer or CSS can do the job with no listener at all.

Debounce, trailing edge, 150 to 300 ms. The user only cares about the query they end up with, and each request costs the server real work. Pair it with three things: flush on Enter so the search doesn't wait, a minimum length (often 2 characters) so a alone doesn't query the whole catalogue, and a small cache keyed by query so typing lam, deleting a letter, and typing it again costs nothing.

Debounce cuts the number of requests but does not stop a slow answer for lam from arriving after the answer for lamp. Cancel the older request with AbortController, or ignore any response that isn't for the latest query. The Request races lesson covers both in depth, and the Autocomplete problem guide puts all of these pieces together in one design.

Window resize

It depends on what the resize does. If you recompute something heavy, like re-laying out a chart or choosing a different image set, debounce by 100 to 200 ms and do it once the drag ends. If something on screen must follow the window edge as it moves, use a frame throttle. Often you need neither: CSS media queries and container queries react to size with no JavaScript, and ResizeObserver tells you when one element changes size, which is usually what you actually wanted.

Scroll

Scroll-linked visuals, like a progress bar or a header that shrinks, want a frame throttle. Scroll-linked logic, like "load the next page when the user is near the bottom" or "mark this item as seen", wants no scroll listener at all: IntersectionObserver tells you when an element enters the viewport, and the browser does the checking as part of rendering, so none of your code runs on every scroll. A throttled scroll listener is the fallback when neither fits.

Analytics

Analytics events, such as scroll depth, video progress, or mouse heat maps, want a time-based throttle, often of 1 to 5 seconds, or a buffer that sends a batch every few seconds. The trap is the end of the session: the last throttled or buffered events are still waiting when the user closes the tab. Flush them on visibilitychange when the page becomes hidden, and send them with navigator.sendBeacon() or fetch with keepalive: true, which are allowed to finish after the page goes away.

Event and jobToolStarting settingWatch out for
input to fetch suggestionsDebounceTrailing, 200 msStale responses; flush on Enter
input to validate a fieldDebounceTrailing, 400 msValidate at once on blur
Editor change to autosaveDebounce with max wait1000 ms, max 5000 msFlush on page hide
resize to re-lay out a chartDebounceTrailing, 150 msPrefer ResizeObserver
scroll to move a progress barrAF throttleOnce per frameRead before you write
scroll to load moreNoneIntersectionObserverObserve an empty marker element at the end
pointermove to dragrAF throttleOnce per frameUse coalesced events for drawing
Scroll depth to analyticsThrottle or batchEvery 2000 ms, trailing onFlush with sendBeacon
Click on "Pay"Leading debounce, plus a disabled button1000 msIdempotency key on the server

Try it with your own hands. Type into the box below and watch three counters: how many input events the browser fired, how many times a 300 ms debounce ran, and how many times a 300 ms throttle ran. Type fast, then slowly, then hold a key down.

Wait:
Runs so far for each handler, and the last value each one saw
HandlerRunsLast value it saw
Every input event0(nothing yet)
Debounce, 300 ms0(nothing yet)
Throttle, 300 ms0(nothing yet)

Type a few words at your normal speed, then pause.

Decision How should a notes app autosave while the user types?

Trailing debounce

Save 1000 ms after the last keystroke.

Flush on page hide and before navigation.

Pros

  • Fewest saves: one per pause in typing.
  • Simple to explain and test.

Cons

  • A user who types without pausing for ten minutes saves nothing for ten minutes.
  • A crash or dead battery loses the whole stretch.

Throttle

Save at most every 3000 ms while typing, with a trailing save at the end.

Pros

  • Work is never more than 3 seconds old.
  • Steady, predictable load.

Cons

  • Saves during short pauses that a debounce would merge.
  • More requests per session.

Debounce with max wait

Save 1000 ms after typing stops, but at least every 5000 ms while it continues.

Flush on page hide.

Pros

  • Few saves for normal typing, and a hard limit on how much can be lost.
  • Matches how people type: bursts and pauses.

Cons

  • Needs a library option or a slightly longer helper.
  • Two numbers to tune instead of one.

What we pick: Debounce with max wait is the usual answer for autosave: it saves once per pause like a debounce, but a user who never pauses still loses at most a few seconds of work. Whatever you pick, flush on visibilitychange so closing the tab never drops the last edit.

What goes wrong in production

The helpers are small, but the bugs around them are common and easy to miss in code review. Most of them come from where the wrapper lives, when it gets cleaned up, and what happens to the call that is still waiting.

A new wrapper on every render

This is the most common bug in React code. A component writes const search = debounce(fetchResults, 300) in its body. Every keystroke changes state, the component renders again, and the render creates a brand new debounced function with its own empty timer. Each keystroke gets its own wrapper, each wrapper waits 300 ms, and every keystroke still sends a request, just 300 ms late.

Two halves. Left, a new wrapper every render: three keystrokes cause three renders, each creates its own debounced wrapper with its own timer, and each wrapper sends a request, so three keys make three requests. Right, one wrapper kept in a ref: all three renders call the same wrapper with one shared timer, so three keys make one request.new wrapper every renderone wrapper, keptrender 1wrapper 1reqrender 1render 2wrapper 2reqrender 2render 3wrapper 3reqrender 31 wrapper1 shared timerreq3 keys, 3 requests3 keys, 1 request
On the left every render calls debounce() again, so nothing ever cancels anything. On the right the wrapper is made once with useMemo and calls the newest callback through a ref.

The fix is to create the wrapper once and keep it across renders, while still calling the newest version of your function, so it sees the current props and state. Keeping the newest function in a ref does both. Cancel on unmount, so a timer never fires into a component that is gone.

tsx
function useDebouncedCallback<A extends unknown[]>(fn: (...args: A) => void, wait: number) {
  const latest = useRef(fn);
  useEffect(() => {
    latest.current = fn; // the newest props and state, without a new wrapper
  });
  const debounced = useMemo(() => debounce((...args: A) => latest.current(...args), wait), [wait]);
  useEffect(() => () => debounced.cancel(), [debounced]); // nothing fires after unmount
  return debounced;
}

function SearchBox() {
  const [query, setQuery] = useState("");
  const search = useDebouncedCallback((text: string) => fetchSuggestions(text), 250);
  return (
    <input
      value={query}
      onChange={(event) => {
        setQuery(event.target.value); // the field updates at once
        search(event.target.value); // the request waits for a pause
      }}
      onKeyDown={(event) => event.key === "Enter" && search.flush()}
    />
  );
}
One wrapper for the life of the component, always calling the latest function.

Notice what is not debounced: setQuery. If you debounce the state that drives the input's value, the letters appear late or get lost, and the page feels broken. The opposite mistake hurts too: heavy work run directly in the input handler delays the next paint, which is what Interaction to Next Paint (INP), the Core Web Vital for input responsiveness, measures. Keep the input instant and debounce only the expensive work that follows it.

The last call goes missing

  • No trailing edge. A leading-only throttle drops the final scroll position or the final slider value. The page settles on a value the user never chose. Turn the trailing edge on for anything that shows state.
  • Unmount or navigation cancels a save. Cancelling on unmount is right for a search, but wrong for an autosave. Call flush, not cancel, for work that must not be lost.
  • The tab closes. A debounced save or a throttled analytics event is still waiting when the user closes the tab. Flush on visibilitychange (when document.visibilityState becomes hidden) and use sendBeacon or keepalive so the request survives.

Timers in background tabs

Browsers slow down timers in hidden tabs to save battery. In Chrome, timers in a hidden tab are checked about once a second, so a throttle that expects to fire every 200 ms fires about once a second instead. After the page has been hidden for 5 minutes, Chrome checks chained timers (5 or more in a row, each set inside the previous one's callback) only once a minute, on a page that has been silent for 30 seconds.

Other browsers have their own rules, and mobile browsers often suspend a background tab completely. rAF callbacks don't run at all while the tab is hidden. Don't build anything that must keep exact time in a hidden tab on these helpers.

Waits that are too long or too short

A 1,000 ms debounce on search feels like the page ignored the user. A 50 ms debounce barely merges anything for a normal typist. Pick the wait from the job, then check it with real people and a network panel. On slow devices and slow networks, the request time adds to the debounce time, so the total the user feels is wait plus round trip plus render.

Noise for screen reader users

Search pages often announce "12 results" in an aria-live region. If that text changes on every keystroke, a screen reader user hears a stream of half-finished counts that interrupt each other. Debounce the announcement too, by about 500 to 1,000 ms, so it speaks once the results settle. This is one of the few places where debounce is an accessibility fix rather than a performance one.

Check your understanding · Exercise 5

A React search box calls debounce(fetchResults, 300) directly in the component body and still sends one request per keystroke. Which fixes work? Select all that apply.

In libraries, frameworks, and the platform

You rarely need to ship your own helper in production: well-tested versions exist. Knowing what each one does by default, and when the platform already solves the problem, matters more than the code.

The helpers you'll meet

LibraryDebounceThrottleNotes
Lodashdebounce(fn, wait, { leading, trailing, maxWait })throttle(fn, wait, { leading, trailing })Throttle is a debounce with maxWait equal to wait; both have cancel and flush
es-toolkitdebounce(fn, ms, { edges, signal })throttle(fn, ms, { edges, signal })A smaller, modern take: an edges array instead of leading and trailing, an AbortSignal to cancel, no maxWait. Its es-toolkit/compat build copies Lodash's API, maxWait included
RxJSdebounceTime(ms)throttleTime(ms), auditTime(ms)auditTime is a trailing-only throttle; streams also cancel stale requests with switchMap
ReactNo helperNo helperuseDeferredValue lowers the priority of slow rendering, but doesn't cut network requests
TanStack Query, SWRNo helperNo helperDebounce the value that goes into the query key; the library dedupes and caches

RxJS is worth knowing because it names the variants cleanly. debounceTime waits for quiet. throttleTime emits the first value and then ignores values for a window. auditTime ignores values for a window and then emits the latest one, a trailing throttle.

Combined with switchMap, a search stream drops the old request the moment a new query arrives, which solves the race problem in the same chain.

React's useDeferredValue is not a debounce

useDeferredValue(query) gives you a copy of query that may lag behind during a busy render. React renders the urgent update (the input) first and the slow part (a big filtered list) in the background, dropping work that a newer keystroke makes useless. There is no fixed delay: on a fast device the deferred value catches up almost at once.

Use it when the slow part is rendering on the client, such as filtering 5,000 rows in memory. It does nothing for network cost: if the deferred value drives a fetch, you still fetch on nearly every keystroke.

For requests, debounce; for slow renders, defer. Big lists may also need virtualization, so only the visible rows render at all.

When the platform does it better

Many throttled listeners are really a workaround for a missing API. The browser now has observers that do the watching for you and call back only when something you care about changes. They don't fire on every frame, and they don't make you read layout on the main thread at the wrong moment.

A matrix of four tools against four jobs. A throttled scroll listener is OK for loading more, lazy images, and a sticky header shadow, and no use for fitting a chart. IntersectionObserver is best for loading more and the header shadow, OK for lazy images, and no use for fitting a chart. ResizeObserver is best for fitting a chart and no use for the rest. HTML and CSS are best for lazy images with the loading attribute, OK for fitting a chart with container queries and for the header with scroll-driven animations, and no use for loading more.load morelazy imgfit chartheaderthrottled scrollIntersectionObserverResizeObserverHTML and CSSokoknobestbestoknobestnonobestnonobestokok
The throttled listener works almost everywhere but is rarely the best fit. For each job there is usually a tool that only calls you when something you care about changes, like loading="lazy" for images.
  • IntersectionObserver: tells you when an element enters or leaves the viewport, or crosses a threshold. Use it for infinite scroll, lazy images, "seen" tracking, and sticky header triggers.
  • ResizeObserver: tells you when a specific element changes size, for any reason, not only window resizes. Use it for charts, text that must fit a box, and container-dependent layouts.
  • CSS: media queries, container queries, position: sticky, and scroll-driven animations (animation-timeline: scroll(), where the browser supports them) move work off JavaScript entirely.
  • Server limits: a throttle in the browser is polite, not a guarantee. Anyone can call the API directly, so the server still needs its own rate limit, answering 429 Too Many Requests, often with a Retry-After header.

Using it in a system design interview

Debounce and throttle come up in almost every frontend design question with user input: autocomplete, search pages, infinite feeds, collaborative editors, maps, and dashboards. They are small, so the signal is not knowing the words. It is placing the right one in the right spot with a number and a reason, and knowing what it does not solve.

A wireframe of a search page with the text lamp desk in the box and three suggestions below it, and a status line saying 3 results. Notes say: the input is instant and never debounced; the fetch is debounced by 250 ms and aborts the old request; the live result count is debounced by about 1 second for screen readers.shop.example/searchlamp desklamp desk, whitelamp desk, LEDlamp desk, clamp3 resultsinput: instantnever debouncedfetch: debounce250 ms + abortlive count:debounce 1 s
What to draw in an interview: the limiter sits between the input and the work, each with its own setting. The input never waits; the network and the announcement do.

The wireframe is the picture to draw. Each arrow from a note points at one place where time is managed differently: the input reacts on every key, the fetch waits for a pause, and the screen reader announcement waits even longer. Saying why each one has its own number is what shows you understand the tools rather than just their names.

What to say, and when

  1. In the requirements, when you hear "as the user types" or "as the user scrolls", note the event rate: "keystrokes arrive every 100 to 150 ms, so I'll merge them".
  2. In the architecture, draw the limiter as its own box between the event and the work, with the setting written on it: "debounce 250 ms", "rAF throttle", "throttle 2 s, flush on hide".
  3. In the optimizations, give the arithmetic: "8 keys per query becomes 1 to 2 requests, roughly a 4 to 8 times cut in suggestion traffic". Then name what it doesn't fix: stale responses, which you handle with AbortController, and the server's own rate limit.
  4. If asked to code it, write the trailing debounce first, then add cancel and flush, then the throttle with both edges. Say out loud why the trailing run must open a new window.

How deep to go by level

LevelExpectedStands out
JuniorKnows debounce versus throttle and picks debounce for searchWrites a correct trailing debounce from memory
MidLeading and trailing edges, a sensible wait, cancel on unmount, the React re-render bugUses rAF for visual work and IntersectionObserver instead of scroll listeners
SeniorTies wait times to user perception and server load, flushes on page hide, separates input echo from derived workKnows the limits: races, background tab timers, server rate limits, accessibility of live announcements

Check your understanding · Exercise 6

You're designing an infinite news feed with a "you've read 40%" indicator and per-post view tracking. Which choices would an interviewer like to hear? Select all that apply.

Summary

  • Debounce waits for a pause and runs once with the latest arguments; throttle runs at most once per interval while calls continue.
  • Leading edge means an instant reaction with the first arguments; trailing edge means a delayed run with the latest. Know your library's defaults, and keep a trailing edge for anything that shows state.
  • A debounce can starve during a long burst. Use throttle, or debounce with a max wait, when the user must see progress.
  • For pixels, throttle to the frame with requestAnimationFrame. For requests, saves, and analytics, use time-based helpers and flush on page hide.
  • Debounce the work, never the input. In React, create the wrapper once, call the latest callback through a ref, and cancel or flush on unmount.
  • Debounce does not fix races, errors, or server load on its own: pair it with AbortController, caching, and a server rate limit.
  • Prefer platform tools when they fit: IntersectionObserver instead of scroll listeners, ResizeObserver instead of window resize, CSS instead of JavaScript.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the difference between debounce and throttle, and when would you use each?

Question 2

Implement a debounce with cancel and flush. What are the edge cases?

Question 3

What do leading and trailing edges mean, and what goes wrong without a trailing edge?

Question 4

In an autocomplete, you debounce requests by 300 ms. Is that enough?

Question 5

Why use requestAnimationFrame to throttle a scroll or pointer handler instead of a 16 ms timer?

Practice it

These design problems lean on debounce and throttle.

References

Read next