What you'll learn: By the end of this lesson you will be able to explain the difference between debouncing and throttling, choose leading, trailing or both edges for a given event, say why a timer-based wait is a minimum and not a promise, build a search-as-you-type box that debounces, cancels the previous request and ignores stale responses, and test all of it on a manual clock without waiting.
Think of the door of a lift. If it closed the instant someone stepped in, a group walking toward it would keep triggering it. A sensible lift waits until nobody has come through for a few seconds, then closes once. That is debouncing: many noisy signals become one action, taken after things go quiet. Now think of a bus that leaves every ten minutes whether it is full or empty. That is throttling: however many people arrive, the action happens at most once per interval.
Both exist for the same reason. A browser can fire events far faster than your code, or your server, should react to them. The running example is the search box on a grocery catalogue page (the front end of the ShopStream marketplace used elsewhere in this course): as the shopper types, the page asks a search API for suggestions. A second example is a scroll handler that updates a "reading progress" bar. The first one needs debouncing, the second needs throttling, and the first one also hides a nasty race that debouncing alone does not fix.
The core idea
Keep three statements in mind.
- Both techniques limit how often a function runs; they differ in what they wait for. Debounce waits for quiet and then runs once. Throttle runs at a steady maximum rate while the activity continues.
- Neither makes the work cheaper or the answers arrive in order. If the handler is slow, it is still slow once it runs (see the event loop and the main thread). If you start a network request, its response can still arrive after a newer one.
- A wait you set is a minimum, not a guarantee. Timers fire late when the page is busy, so correct code compares timestamps and does not trust that a timer fired "exactly" on time.
MDN's glossary defines debouncing as discarding "operations that occur too close together during a specific interval" and consolidating them "into a single invocation". It names the first call the leading edge and the end of the quiet period the trailing edge, and notes that the function usually runs on the trailing edge only, but sometimes on the leading edge or both.
Debounce: wait for quiet
A debounced function restarts a timer on every call. Only when the timer is allowed to finish, because no further call arrived in time, does the real function run, with the arguments of the last call.
Illustration 1: the minimal example
function debounce(fn, waitMs) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), waitMs);
};
}
const search = debounce((query) => console.log("search:", query), 100);
search("b");
setTimeout(() => search("ba"), 20);
setTimeout(() => search("ban"), 40);
Output, from running it in Node (three runs printed the same):
search: ban
Three calls, one run, with the last argument. That is the whole idea. Everything below is about the edges of this toy: what if you want the first call to run at once, what if the user presses Enter before the wait is over, what happens on unmount.
Figure 1. A debounce as a state machine
How to read this: there are only two states. The behaviour is in the arrows out of Waiting. A real implementation adds the leading edge on the arrow into Waiting and a maxWait guard so that a very long burst still runs.
Edges, cancel and flush
Lodash's documentation shows the vocabulary most libraries copy. _.debounce(func, [wait=0], [options]) takes leading (default false), trailing (default true) and maxWait ("the maximum time func is allowed to be delayed before it's invoked"). The debounced function has a cancel method to drop delayed invocations and a flush method to run them immediately. With both edges on, func runs on the trailing edge "only if the debounced function is invoked more than once during the wait timeout", so a lone call does not run twice.
| Need | Edges | Example |
|---|---|---|
| Act when the user pauses | trailing only | search suggestions, validation, autosave |
| Act at once, ignore the burst | leading only | a "save" button that must not fire twice |
| Feel instant, then settle | both | a filter that shows something right away and the final answer after |
| A long burst must not starve the action | trailing plus maxWait |
autosave while typing a long note |
| The user says "now" | flush |
Enter key in a search box, leaving the page |
The matching real-world rule for flushing is: anything that means "I am done" (Enter, blur, closing a panel) should flush, and anything that means "this is no longer relevant" (unmount, navigation) should cancel.
Throttle: a steady maximum rate
A throttled function runs at most once per interval while events keep arriving. Use it when you need regular samples of a continuous stream: scroll position, pointer movement, window resize, progress reports.
Illustration 2: a minimal throttle, and the reason it is not enough
function throttleLeading(fn, waitMs, now = () => performance.now()) {
let last = -Infinity;
return (...args) => {
const time = now();
if (time - last >= waitMs) {
last = time;
fn(...args);
}
};
}
let t = 0; // a fake clock, so the output does not depend on real timing
const onScroll = throttleLeading((label) => console.log(`t=${t} handled ${label}`), 100, () => t);
for (const [time, label] of [[0, "a"], [30, "b"], [60, "c"], [130, "d"]]) {
t = time;
onScroll(label);
}
Output (run in Node):
t=0 handled a
t=130 handled d
Calls b and c are discarded. That is fine for a scroll progress bar that will be corrected by the next event. It is wrong when the last value matters: if the user stops scrolling at c, the page never learns the final position. A complete throttle also has a trailing edge that runs the newest call when the interval ends. Writing that correctly, including cancel, flush and the "never closer than the interval" rule, is the point of the existing lab examples/frontend/labs/debounce-throttle, which builds both as pure state machines and is the version this lesson relies on. Lodash's _.throttle has the same leading and trailing options, both on by default, and the same cancel and flush.
Figure 2. Which tool for which event
values in between?"} A -->|"no, only the final one"| B["Debounce
(trailing edge)"] A -->|"yes, regular samples"| C{"Is the work
drawing something?"} C -->|"yes"| D["requestAnimationFrame
(once per frame)"] C -->|"no"| E["Throttle
(leading plus trailing)"] A -->|"no, react now and
ignore repeats"| F["Leading edge only
(debounce or throttle)"]
How to read this: ask what you lose by skipping values. If nothing, debounce. If a smooth stream, sample it. For drawing, MDN describes requestAnimationFrame as calling your function "before the next repaint", with a call frequency that generally matches the display refresh rate; it is one-shot, so the callback must request the next frame itself.
A timer is a minimum, not a promise
Both techniques lean on setTimeout. MDN's page lists why it can fire later than asked: a nested timer is clamped to a minimum of 4 ms after five nested calls (per the HTML standard), browsers throttle timers in background tabs, and "the timeout can also fire later than expected if the page (or the OS/browser) is busy with other tasks". A callback also cannot run until the code that is currently running has finished.
Two consequences. First, a "100 ms debounce" means "at least 100 ms of quiet, and then the first free turn of the event loop". Second, code that has to be correct should record the time of the last call and compare it with the time now when the timer fires, re-arming for the remainder if it fired early. The lab's reducers do exactly that.
Debounce does not fix the race
The most common bug in search-as-you-type is not too many requests; it is the wrong answer on screen. The React documentation describes it with the example of typing "hello": separate fetches start for each prefix, "there is no guarantee about which order the responses will arrive in", so the response for "hell" may arrive after the one for "hello", and the page ends up showing the wrong results. It calls this a race condition.
Debounce reduces how many requests start, which makes the race less likely, but one slow early request is enough to cause it. Two separate guards fix it, and they do different jobs.
- Cancel the previous request (
AbortController). Aborting afetchrejects its promise, by default with anAbortError. This saves bandwidth and server work, and the old response can never be handled. It is cooperative: the server may already be working on it (the lesson Request Cancellation, Timeouts and Race Conditions covers what an abort does and does not undo). - Ignore stale responses (a request id, or the
ignoreflag from the React docs). Even if cancellation is incomplete, a response that is not for the newest request is dropped before it touches the view.
Figure 3. The typeahead pipeline
{
"title": "Typeahead pipeline: debounce, cancel the old request, drop stale answers",
"nodes": [
{ "id": "keys", "label": "Keystrokes", "sub": "input events", "glyph": "user", "at": [0, 0] },
{ "id": "deb", "label": "Debounce", "sub": "wait for a pause", "glyph": "clock", "at": [1.2, 0], "tone": "accent" },
{ "id": "mgr", "label": "Request manager", "sub": "abort previous, send new", "glyph": "service", "at": [2.4, 0], "tone": "accent" },
{ "id": "api", "label": "Search API", "sub": "answers in any order", "glyph": "cloud", "at": [3.6, 0] },
{ "id": "guard", "label": "Stale guard", "sub": "newest request only", "glyph": "security", "at": [2.4, 1.6], "tone": "accent" },
{ "id": "view", "label": "Results list", "sub": "what the shopper sees", "glyph": "browser", "at": [0.6, 1.6] }
],
"zones": [
{ "label": "Browser", "nodes": ["keys", "deb", "mgr", "guard", "view"] }
],
"edges": [
{ "from": "keys", "to": "deb" },
{ "from": "deb", "to": "mgr", "label": "last text" },
{ "from": "mgr", "to": "api", "label": "fetch + signal" },
{ "from": "api", "to": "guard", "label": "response", "bend": "below" },
{ "from": "guard", "to": "view", "label": "if newest" }
]
}
How to read this: the three accent boxes are code you write. The API can answer in any order, so nothing reaches the list unless the guard confirms the answer belongs to the newest request.
Figure 4. Two requests in flight
How to read this: abort and the stale check protect the same moment from two sides. Abort stops the old work; the check makes sure that, if the old answer arrives anyway, it is thrown away.
Illustration 3: a realistic example in the browser
The realistic version for the catalogue page, in plain TypeScript. debounce is the function from the lab or a library one with the same shape.
let controller: AbortController | undefined;
const search = debounce(async (query: string) => {
controller?.abort(); // 1. cancel the previous request
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does not reject on 404 or 504
renderSuggestions(await res.json());
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return; // we cancelled it
showSearchError(error);
}
}, 150); // illustrative wait
input.addEventListener("input", () => search(input.value.trim()));
form.addEventListener("submit", (event) => {
event.preventDefault();
search.flush(); // Enter means "now"
});
Note that fetch() only rejects on a failed request, such as a network error. A 404 or 504 resolves normally, which is why the res.ok check is there. In React the same idea is usually written by debouncing the value and letting an effect clean up after itself:
function useDebouncedValue<T>(value: T, waitMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), waitMs);
return () => clearTimeout(id); // a new value restarts the wait; unmount cancels it
}, [value, waitMs]);
return debounced;
}
function Suggestions({ query }: { query: string }) {
const debouncedQuery = useDebouncedValue(query, 150);
const [items, setItems] = useState<string[]>([]);
useEffect(() => {
if (debouncedQuery.length < 2) { setItems([]); return; }
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
.then(setItems)
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === "AbortError")) console.error(error);
});
return () => controller.abort(); // the effect's cleanup is the cancel
}, [debouncedQuery]);
return <ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>;
}
A common React-specific bug: creating the debounced function in the component body without keeping it stable between renders. Every render then makes a new function with a new timer, and nothing is ever debounced. Debouncing the value, as above, avoids that trap, because the timer lives in an effect and is cleaned up by it. The lesson on effects and synchronization covers the cleanup rule.
Illustration 4: the failure, from the lab
The lab replays the same five keystrokes (a pause in the middle) against a fake server whose first request is slow. This is the block for "debounce, no guards", copied from examples/frontend/golden/typeahead-pipeline.txt:
=== debounce 150 ms, no guards
t= 0 key "b"
t= 90 key "ba"
t= 240 request #1 "ba"
t= 300 key "ban"
t= 390 key "bana"
t= 480 key "banan"
t= 630 request #2 "banan"
t= 690 show #2 "banan"
t= 840 show #1 "ba"
requests: 2 (answered 2, aborted 0)
input "banan", results shown for "ba": bacon, bagel, baguette
BUG: the results are for an older query
Debounce cut four searchable keystrokes down to two requests, and the page still ended up wrong, because request 1 took longer than request 2. All the times are virtual milliseconds and the latencies are illustrative.
Debounce is not a defence for the server
Debouncing is a courtesy of one well-behaved client. A script, a second tab, or a client without your code will not wait. Protecting the search API is the job of the server, with limits, quotas and shedding (see rate limiting). Treat client-side debounce as an optimisation of user experience and cost, never as a control.
The other side of this topic
On the back end the same pressure appears as a stream of requests that must be bounded at the service: token buckets, per-client limits and queues. A front end that debounces and a back end that rate-limits cooperate: the first keeps ordinary use quiet, the second keeps the system safe from everything else.
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| Longer debounce wait | Fewer requests, less churn | Results appear later; feels sluggish |
| Shorter debounce wait | Feels responsive | More requests; more race exposure |
| Leading edge | Instant first reaction | A burst's later values are ignored unless trailing is on too |
| Throttle instead of debounce | Steady updates during activity | Work runs while the user is still changing their mind |
AbortController alone |
Saves work | Not enough if the abort arrives too late or a library ignores the signal |
| Stale guard alone | Correct view | Wasted requests still complete |
| Both guards | Correct view and less waste | A little more code to test |
Build it
The lab examples/frontend/labs/typeahead-pipeline builds the pipeline above on top of the existing lab examples/frontend/labs/debounce-throttle (which already covers leading, trailing, cancel and flush as pure reducers; start there for the state machines) and on the virtual clock of the request-client lab. It adds a fake search server where you choose each request's latency, so responses can arrive out of order, and two independent switches, abortStale and ignoreStale.
Run it from the repository (no install is needed beyond the examples workspace):
cd examples/frontend/labs/typeahead-pipeline
node src/demo.ts
It prints five configurations. Two of them, copied from the golden file (examples/frontend/golden/typeahead-pipeline.txt), show the guards at work:
=== debounce 150 ms, ignore stale responses
t= 0 key "b"
t= 90 key "ba"
t= 240 request #1 "ba"
t= 300 key "ban"
t= 390 key "bana"
t= 480 key "banan"
t= 630 request #2 "banan"
t= 690 show #2 "banan"
t= 840 drop #1 "ba"
requests: 2 (answered 2, aborted 0)
input "banan", results shown for "banan": banana, banana bread
OK: the results match the input
=== debounce 150 ms, abort the previous request
t= 0 key "b"
t= 90 key "ba"
t= 240 request #1 "ba"
t= 300 key "ban"
t= 390 key "bana"
t= 480 key "banan"
t= 630 abort #1 "ba"
t= 630 request #2 "banan"
t= 690 show #2 "banan"
requests: 2 (answered 1, aborted 1)
input "banan", results shown for "banan": banana, banana bread
OK: the results match the input
Read the difference: with the stale guard the slow request completes and its answer is dropped at 840; with abort it never completes (answered 1, aborted 1). The first row of the demo, with no debounce at all, sends four requests for the same typing instead of two.
Verified. The 18 tests in the lab passed with Node 26.12.1 and Vitest 5.1.0, and the type check passed. They cover: one request per burst, Enter flushing, input below the minimum length, the race with no guard (the bug is asserted), each guard alone, both guards, clearing the box, unmount, errors, and a seeded property test over 40 random typing patterns that checks the final view always matches the last request.
Break it. In src/typeahead.ts replace the stale check if (ignoreStale && myEpoch !== epoch) { with if (false) {. Two tests fail: the "ignoring stale responses" test and the seeded property test for that setting. Revert it.
Stretch. Add a small cache keyed by query, so typing ban, backspace, ban makes one request, and decide how it interacts with the stale guard.
In practice: live filtering on a product listing page
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A grocery listing page has a search box and a "reading progress" bar for long category descriptions. The search box calls an API for suggestions on every input event, and the progress bar recomputes layout-dependent values in a scroll handler. During a promotion the search API gets noticeably slower, and customers report two things: the page janks while scrolling on phones, and the suggestions sometimes "do not match what I typed".
Starting architecture
Figure 5. Every event goes straight to work
{
"title": "Starting architecture: each input and scroll event does work immediately",
"nodes": [
{ "id": "input", "label": "Input events", "sub": "one per keystroke", "glyph": "user", "at": [0, 0] },
{ "id": "scroll", "label": "Scroll events", "sub": "many per frame", "glyph": "user", "at": [0, 1.6] },
{ "id": "fetch", "label": "Fetch per keystroke", "sub": "no cancel, no guard", "glyph": "service", "at": [1.4, 0], "tone": "warn" },
{ "id": "measure", "label": "Measure layout, update bar", "sub": "in the scroll handler", "glyph": "gauge", "at": [1.4, 1.6], "tone": "warn" },
{ "id": "api", "label": "Search API", "sub": "slower under load", "glyph": "cloud", "at": [2.8, 0] },
{ "id": "ui", "label": "UI", "sub": "last response wins", "glyph": "browser", "at": [2.8, 1.6], "tone": "warn" }
],
"edges": [
{ "from": "input", "to": "fetch" },
{ "from": "scroll", "to": "measure" },
{ "from": "fetch", "to": "api" },
{ "from": "api", "to": "ui", "label": "any order", "bend": "right" },
{ "from": "measure", "to": "ui" }
]
}
How to read this: the warning boxes are where the problems start. Nothing limits how often work runs, and the UI shows whichever response arrived last.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Debounce the search only | Fewer requests | A delay before suggestions | The wrong-results bug remains |
| Debounce plus abort plus stale guard | Fewer requests and a correct view | A request manager to test | Library code that ignores the signal |
| Throttle the scroll handler | Fewer measurements | Progress bar lags a little | Last position lost unless trailing is on |
Replace the scroll work with an IntersectionObserver-style approach |
No per-event work | A redesign of the bar | Out of scope for this fix |
The decision
| Field | Decision record |
|---|---|
| Context | Search fires per keystroke and the view can show an older answer; the scroll handler measures layout on every event. |
| Decision | Debounce the search (trailing edge, with Enter flushing), abort the previous request, drop stale responses. Throttle the scroll handler with leading and trailing edges and read layout once per call. |
| Consequences, good | Fewer calls to the API; the view always matches the input; steadier scrolling. |
| Consequences, bad | Suggestions appear after the wait; the progress bar updates at a coarser rate. |
| Revisit when | Field data shows the wait is too long for users, or the scroll work can be replaced by an observer. |
Rollout and verification
- Reproduce the wrong results with a slow fake server (the lab's approach) before changing the code, so the fix has a failing test to go green on.
- Ship behind a flag to a share of sessions and compare the number of search requests per session and the rate of "no results" views.
- Test the edges: Enter right after typing, clearing the box while a request is in flight, navigating away, and returning to the page.
- Check scroll smoothness with the browser's performance tools on a throttled CPU, and confirm the final scroll position is always reflected.
The 2 a.m. failure
A week later, support reports that on a small set of pages the suggestions list shows results for the previous word. Only some pages, and only at busy hours.
The cause is a new "search within category" component that a different team wrote. It debounces correctly but copies an older fetch helper that neither cancels nor checks which request an answer belongs to. At busy hours the API's latency varies more, so one early request occasionally outlasts a later one. Debouncing had hidden the problem in quiet hours simply because fewer requests overlapped.
First three checks: (1) log the request id or query next to each rendered result and look for a render that does not match the newest query; (2) reproduce with a fake server that delays one request; (3) search the code for fetch( calls without a signal or a staleness check. Mitigation: switch the category search to the shared request manager. Follow-up: put the guard in one shared module, add a lint rule or review item against raw fetch in input handlers, and run the lab-style slow-first-request test in CI for every component that searches while typing.
Architect's notes
- Pick the primitive by what you can afford to lose. If skipped values are harmless, debounce; if you need a steady stream, throttle; if it is drawing, use
requestAnimationFrame. - Make the guards part of one shared request helper. The bug above appears when each team re-implements fetching.
- Choose waits from observation, not folklore. Any wait value is a UX trade-off; measure how people type and how long your API takes, and treat numbers you read elsewhere as starting points.
- Debounce the work, not only the event. Expensive handlers still block when they finally run; see task scheduling and yielding and INP.
- Always define what Enter, blur and unmount do. Flush on "done", cancel on "irrelevant".
Common mistakes
- Debouncing and assuming the response order is solved. It is not; add cancellation and a stale guard.
- Creating a debounced function on every render. Each render has its own timer, so nothing is debounced.
- Forgetting to cancel on unmount. A pending call can fire after the component is gone.
- Throttling without a trailing edge when the last value matters.
- Treating a
setTimeoutwait as exact. It is a minimum; compare timestamps when correctness depends on it. - Using debounce as rate limiting. A client that does not run your code is not debounced.
- Forgetting
res.ok.fetchdoes not reject on HTTP error statuses.
Key takeaways
- Debounce runs once after activity stops; throttle runs at most once per interval while it continues.
- Leading, trailing or both edges are a product decision, and
cancelandflushare part of the design. - A timer wait is a minimum; correct code compares timestamps.
- Debouncing reduces requests; it does not order responses.
- Fix the race with two guards: abort the previous request, and drop responses that are not for the newest one.
- Flush on "I am done", cancel on "this no longer matters".
- In React, debounce the value or keep the debounced function stable, and let effect cleanup cancel.
- Client-side debounce is not a defence for the server.
- Test with a manual clock and a fake server whose latency you control.
Practice exercises
Exercise 1: Starter (30 min). Run both snippets in Node. Change the debounce wait and the call times so that the function runs twice, and predict the output before you run.
Exercise 2: Intermediate (2 hours). Run the lab's demo, then turn off each guard in turn in a copy of typeahead.ts and find which test notices. Add a test for the case "the user clears the box while the debounce is waiting".
Exercise 3: Intermediate (2 hours). Write the useDebouncedValue hook with React Testing Library and fake timers. Assert that unmounting cancels the pending update.
Exercise 4: Advanced (half day). Add maxWait to the debounce so that a 10-second burst still searches every few seconds, and add a replay test that proves the maximum gap.
Related reading
In this course:
- The Event Loop and the Main Thread: why a handler that finally runs can still block.
- Request Cancellation, Timeouts and Race Conditions: what
AbortControllerdoes and does not undo, plus deadlines and hedged requests. - Task Scheduling and Yielding: breaking up the work you do run.
- INP and Responsiveness: how handler cost shows up in the metric.
- React Effects and Synchronization: effect cleanup, which is where the cancel belongs.
Elsewhere on the site:
- System Design: Rate Limiting, the server-side counterpart.
Official documentation
- MDN Glossary: Debounce: the definition, leading and trailing edges, the search-box use.
- Lodash documentation:
_.debounceand_.throttle:leading,trailing,maxWait,cancel,flush. - MDN: AbortController and Window.fetch(): aborting requests;
fetchdoes not reject on HTTP error statuses. - MDN: Window.setTimeout(): why a timeout can fire late.
- MDN: Window.requestAnimationFrame(): once per frame, one-shot.
- React: You Might Not Need an Effect: the fetching-data race condition and the cleanup function.