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.
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.
- At 0 ms the user types
l. No timer is running. The wrapper savesland starts a timer for 300 ms. - 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 nowlamp, and the timer will fire at 630 ms. - From 330 ms to 630 ms nothing happens. The timer fires at 630 ms and the search for
lampruns. That is the first and only request so far. - At 900 ms the user types a space and keeps typing
deskuntil 1,350 ms. Each key restarts the timer again. - At 1,650 ms, 300 ms after the last key, the search for
lamp deskruns. Nine keystrokes became two requests.
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.
?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.
| Use | Typical wait | Why |
|---|---|---|
| Search suggestions | 150 to 300 ms | Longer than the gap between keys, short enough to feel live |
| Form field validation | 300 to 500 ms | Don't shout "invalid email" while the user is still typing it |
| Autosave a draft | 1000 to 2000 ms | Saves are heavier; the user doesn't watch for them |
| Layout work after a window resize | 100 to 200 ms | Run 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.
- At 100 ms the first scroll arrives. No window is open, so
reportDepthruns at once (the leading edge) and a 400 ms window opens. - From 200 to 400 ms three more scrolls arrive. Each one only saves the newest depth.
- At 500 ms the window ends. A call is waiting, so
reportDepthruns with the newest depth, and a new window opens. - This repeats at 900 ms and 1,300 ms. The scroll at 1,500 ms is the last one.
- 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.
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.
| Question | Debounce | Throttle |
|---|---|---|
| When does it run? | After wait ms with no new calls | At most once per wait ms while calls continue |
| Does a new call move the timer? | Yes, it restarts it | No, it only saves the latest arguments |
| Runs during a 5 second burst | 0 (trailing) or 1 (leading) | About 5000 / wait |
| First reaction | After the pause (trailing) or at once (leading) | At once (leading) or after one window |
| Good for | Search, validation, autosave, resize end | Scroll 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.
| Setting | Debounce does | Throttle does | Typical use |
|---|---|---|---|
| Trailing only | One run, wait ms after the burst | First run after one window, then steady | Search box, autosave, resize end |
| Leading only | One run at the start; later calls ignored until quiet | Run at once, then drop calls until the window ends | Stop a double submit, a "like" button |
| Leading and trailing | A run at the start and one at the end | Instant, steady, and the last value always lands | Scroll position, drag, live preview |
| Neither | Never runs | Never runs | None: 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.
| Events | Runs | First reaction | Last event used? |
|---|---|---|---|
| 17 | 3 | after 750 ms | yes |
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
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;
}
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.
cancelthrows 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.flushruns 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, becausefnhas not run yet when it returns. If you need the result, havefnset state or resolve a promise.
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
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;
}
};
}
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 usesthis, savethisin the wrapper and callfn.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.timersinnode: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.
requestAnimationFrame, the work runs once per frame, right before paint, with the newest values.The code
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));
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
scrollandresizeare 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
pointermoveandmousemoveat most once per frame, just before rAF callbacks, and keeps the in-between points;event.getCoalescedEvents()returns them. So in Chromium a frame throttle onpointermovemostly 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 }totouchstart,touchmove, andwheellisteners tells the browser you won't callpreventDefault(), 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.
Search and autocomplete
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 job | Tool | Starting setting | Watch out for |
|---|---|---|---|
input to fetch suggestions | Debounce | Trailing, 200 ms | Stale responses; flush on Enter |
input to validate a field | Debounce | Trailing, 400 ms | Validate at once on blur |
| Editor change to autosave | Debounce with max wait | 1000 ms, max 5000 ms | Flush on page hide |
resize to re-lay out a chart | Debounce | Trailing, 150 ms | Prefer ResizeObserver |
scroll to move a progress bar | rAF throttle | Once per frame | Read before you write |
scroll to load more | None | IntersectionObserver | Observe an empty marker element at the end |
pointermove to drag | rAF throttle | Once per frame | Use coalesced events for drawing |
| Scroll depth to analytics | Throttle or batch | Every 2000 ms, trailing on | Flush with sendBeacon |
| Click on "Pay" | Leading debounce, plus a disabled button | 1000 ms | Idempotency 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.
| Handler | Runs | Last value it saw |
|---|---|---|
| Every input event | 0 | (nothing yet) |
| Debounce, 300 ms | 0 | (nothing yet) |
| Throttle, 300 ms | 0 | (nothing yet) |
Type a few words at your normal speed, then pause.
Decision How should a notes app autosave while the user types?
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.
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.
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()}
/>
);
}
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, notcancel, 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(whendocument.visibilityStatebecomeshidden) and usesendBeaconorkeepaliveso 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
| Library | Debounce | Throttle | Notes |
|---|---|---|---|
| Lodash | debounce(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-toolkit | debounce(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 |
| RxJS | debounceTime(ms) | throttleTime(ms), auditTime(ms) | auditTime is a trailing-only throttle; streams also cancel stale requests with switchMap |
| React | No helper | No helper | useDeferredValue lowers the priority of slow rendering, but doesn't cut network requests |
| TanStack Query, SWR | No helper | No helper | Debounce 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.
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 aRetry-Afterheader.
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.
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
- 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".
- 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".
- 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. - If asked to code it, write the trailing debounce first, then add
cancelandflush, then the throttle with both edges. Say out loud why the trailing run must open a new window.
How deep to go by level
| Level | Expected | Stands out |
|---|---|---|
| Junior | Knows debounce versus throttle and picks debounce for search | Writes a correct trailing debounce from memory |
| Mid | Leading and trailing edges, a sensible wait, cancel on unmount, the React re-render bug | Uses rAF for visual work and IntersectionObserver instead of scroll listeners |
| Senior | Ties wait times to user perception and server load, flushes on page hide, separates input echo from derived work | Knows 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:
IntersectionObserverinstead of scroll listeners,ResizeObserverinstead 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?
Debounce waits until calls stop for a set time, then runs once with the latest arguments. A new call restarts the timer. Throttle runs at most once per interval while calls keep coming; a new call never moves the timer, it only updates the arguments.
Debounce fits work that only matters once the user is done: search suggestions, field validation, autosave, heavy re-layout after a resize. Throttle fits work the user watches while it happens or that should report steadily: scroll effects, drag, progress, analytics. For anything visual, throttle to the frame with requestAnimationFrame.
Question 2
Implement a debounce with cancel and flush. What are the edge cases?
Keep one timer and the latest arguments in a closure. Each call saves the arguments, clears the timer, and starts a new one. When it fires, clear the state first, then call the function. cancel clears the timer and the saved arguments. flush runs the waiting call now if there is one, and does nothing otherwise.
Edge cases: the wrapper must be created once, not per call or per render; it can't return the function's result; it should keep the latest arguments, not the first; flush with nothing waiting must be a no-op; and old-style methods need this passed through with apply.
Question 3
What do leading and trailing edges mean, and what goes wrong without a trailing edge?
The leading edge runs on the first call of a burst, so the reaction is instant but uses the first arguments. The trailing edge runs when the timer ends with the latest arguments, so it is current but delayed. Lodash's throttle has both on by default; its debounce is trailing only.
Without a trailing edge, the last call of a burst is dropped. A scroll indicator or slider then settles on a value from a few hundred milliseconds before the user stopped, and stays wrong until the next event.
Question 4
In an autocomplete, you debounce requests by 300 ms. Is that enough?
No. Debounce cuts the number of requests, but two requests can still both be sent across a pause, and the slower, older one can answer last and overwrite the newer results. Abort the previous request with AbortController or ignore responses that don't match the latest query.
Around it: flush on Enter, require a minimum length, cache results by query, show a loading state, keep the input itself instant, and debounce any aria-live result count so screen readers don't hear every intermediate count. The server still needs its own rate limit.
Question 5
Why use requestAnimationFrame to throttle a scroll or pointer handler instead of a 16 ms timer?
rAF runs once per frame, right before the browser computes styles and paints, so each update is shown in the very next frame. A 16 ms timer isn't tied to frames: it can fire twice in one frame and waste work, or just after a paint and show a frame late, and it is too slow for a 120 Hz screen.
rAF also adapts to slow frames and pauses in hidden tabs. That pause is a reason not to use it for analytics or saves, which must still happen.
Practice it
These design problems lean on debounce and throttle.
- Design a like button for millions of usersEasy
- Design an autocompleteMedium
- Design a tag (multi-select) inputMedium
- Design a command paletteMedium
- Design an @-mention inputMedium
- Design a markdown editor with live previewMedium
- Design an emoji and GIF pickerMedium
- Design a search results pageMedium
- Design Stack OverflowMedium
- Design a blogging platform like MediumMedium
- Design WikipediaMedium
- Design live comments for a sports streamHard
- Design the Spotify web playerHard
- Design AirbnbHard
- Design Uber's rider web appHard
- Design an online code playground like CodePenHard
- Design an IoT dashboard for thousands of sensorsHard
- Design Google MapsHard
- Design a writing assistant like GrammarlyHard
References
- MDN: Window.requestAnimationFrame() How frame callbacks are scheduled and when they pause.
- MDN: Debounce (glossary) Short definitions of both edges.
- MDN: Throttle (glossary) The companion definition, with scroll as the example.
- Lodash documentation: debounce The options
leading,trailing, andmaxWait, and thecancelandflushmethods. - MDN: Intersection Observer API The replacement for most throttled scroll listeners.
- web.dev: Interaction to Next Paint (INP) Why the input itself must stay fast.
- MDN: Navigator.sendBeacon() Sending the last analytics batch as the page goes away.
Read next
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Deduplication and batchingWhen many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.