Debounced Search with Cancellation
Mental model: Three separate problems live in one search box: too many requests, requests you no longer want, and responses that arrive out of order. Each one needs its own fix.
Level: advanced · about 26 minutes
A search box is the smallest place where three async problems collide, and most production code gets at least one of them wrong. By the end you will have a component that sends one request instead of ten, cancels the ones it no longer wants, and cannot be corrupted by a slow answer to an old question. You will also be able to reproduce the classic search race on demand instead of meeting it once a quarter in production.
The lab searches 42 topics with a local endpoint (a promise around a setTimeout, so it runs offline). You control the debounce (0, 150, 300, 600 ms), a latency profile, and two switches: abort on keystroke, and guard stale responses. The default profile answers short queries slowly, which makes the race reproducible every time. Counters track keystrokes, requests sent, requests never sent because the timer restarted, aborted, and ignored as stale. A progress bar shows the debounce timer restarting as you type.
The build, decision by decision
- Separate the three problems before writing any code Debounce cuts volume. Abort cuts waste. A sequence guard fixes ordering. None of the three substitutes for another, and the lab lets you turn each one off to prove it.
- Write the debounce by hand, once One timer id. Every keystroke clears it and starts a new one, so only the last keystroke in a burst survives to become a request. The timer is the whole mechanism, which is why it is worth writing out rather than importing.
- Do not send a request for an empty query An empty box is not a search. Clear the results, reset the state, and send nothing. This is the most common wasted call in a search feature.
- Abort the request the moment its query is out of date One controller per request. On the next keystroke, abort everything in flight: the reject arrives as an
AbortErrorand the timer standing in for the network is cleared. - Guard the order with a sequence number Abort helps, but a response can already be on the wire when you stop caring. Number every request, remember the highest one you have rendered, and drop anything older. It is four characters of comparison and it is the only real fix.
- Handle the AbortError separately from a real failure A cancellation is not an error the user needs to see. Check
err.nameand treat the two paths differently, which is exactly the check you write against a realfetch. - Design the empty result as carefully as the happy path Zero results is a state, not an accident. Say what was searched for and what to try next, in the same live region that announces a successful search.
- Make it operable from the keyboard, and announce it The input is a combobox, the results are a listbox, arrow keys move the active option, Enter opens it, and a polite live region reports the count. Search is a control, not a page of text.
- Highlight matches without ever touching innerHTML The query is user input. Split the text around the match and build a real
markelement, so a query of<script>is text, not markup. - Clean up on unmount Clear the debounce timer, abort every controller, and drop the ticker that animates the countdown. A search box that keeps searching after its view is gone is a leak with a UI.
The core mechanism
function debounce(fn, waitMs) {
let timer = null;
let lastArgs = null;
const wrapped = (...args) => {
lastArgs = args;
if (timer !== null) clearTimeout(timer); // restart the clock
timer = setTimeout(() => {
timer = null;
fn(...lastArgs);
}, waitMs);
};
wrapped.cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
return wrapped;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const search = debounce((q) => console.log('SEARCH for ' + q), 40);
for (const q of ['j', 'ja', 'jav', 'java']) {
search(q);
await sleep(10); // a fast typist: 10ms between keys
}
await sleep(60);
// -> SEARCH for java (four keystrokes, one request)
search('python');
search.cancel(); // the user pressed Escape
await sleep(60);
console.log('cancelled, so nothing was searched');Debounce plus cancellation: four keystrokes, one call, and a cancel that lands in time.
// The endpoint answers short queries slowly, which is how the race becomes reproducible.
const searchApi = (q, ms) =>
new Promise((resolve) => setTimeout(() => resolve({ q, items: q.length }), ms));
function makeSearch(guard) {
let seq = 0;
let renderedSeq = 0;
return async function search(q, ms) {
const mine = (seq += 1);
const response = await searchApi(q, ms);
if (guard && mine < renderedSeq) {
console.log(' dropped stale response for "' + response.q + '"');
return;
}
renderedSeq = mine;
console.log(' rendered "' + response.q + '"');
};
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
console.log('guard off:');
const loose = makeSearch(false);
loose('jav', 120); // sent first, answers last
loose('java', 20); // sent second, answers first
await sleep(200);
console.log('guard on:');
const tight = makeSearch(true);
tight('jav', 120);
tight('java', 20);
await sleep(200);
// -> guard off:
// -> rendered "java"
// -> rendered "jav" <- the box says java, the list shows jav
// -> guard on:
// -> rendered "java"
// -> dropped stale response for "jav"The race, twice: once without the guard and once with it.
Interactive visualiser: eventloop. Enable JavaScript to use it.
let calls = 0;
const hit = debounce(() => { calls += 1; }, 30);
hit();
await new Promise((r) => setTimeout(r, 20)); // 20ms: not long enough
hit();
await new Promise((r) => setTimeout(r, 20)); // another 20ms, timer restarted
hit();
await new Promise((r) => setTimeout(r, 50)); // now it is quiet
console.log(calls);Each call clears the pending timer and starts a new 30 ms wait, so the first two never fire even though 40 ms of real time passed between them. Debounce waits for quiet, it does not rate limit. A throttle would have fired the first call immediately and then at most once per interval.
Debounce: wait for quiet
// search boxes, autosave,
// resize handlers
let t = null;
const run = (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
// a burst of 10 calls -> 1 call,
// after the burst ends
Throttle: one per interval
// scroll position, drag,
// analytics pings
let last = 0;
const run = (...args) => {
const now = Date.now();
if (now - last < ms) return;
last = now;
fn(...args);
};
// a burst of 10 calls -> a call
// every ms, starting immediatelyAsk what the user is waiting for. If they want the answer to what they finished typing, debounce. If they want continuous feedback while something keeps happening, throttle.
| Problem | Symptom the user sees | Fix | Does the other fix cover it? |
|---|---|---|---|
| too many requests | a spinner flickering on every key | debounce the input | no |
| wasted requests | a slow app and a hot server | AbortController per request | debounce reduces it, never removes it |
| out-of-order responses | the list disagrees with the box | a sequence guard on render | no, the response was already in flight |
| no results | an empty panel with no explanation | design the empty state | no |
Extend it
- Add a minimum query length of two characters, and say why nothing happened when the query is shorter.
- Add
flush()so pressing Enter searches immediately instead of waiting out the debounce. - Cache results per query in a
Map, and show a cached answer instantly while a fresh request runs. - Add
aria-activedescendantso the active option is announced as the arrow keys move, and check it with a screen reader. - Swap the local endpoint for a real API with
fetchandsignal, and keep every counter working.
Try it yourself
Count what debounce saves
function debounce(fn, waitMs) {
let timer = null;
let collapsed = 0;
const wrapped = (...args) => {
if (timer !== null) {
clearTimeout(timer);
collapsed += 1;
}
timer = setTimeout(() => {
timer = null;
fn(...args);
}, waitMs);
};
Object.defineProperty(wrapped, 'collapsed', { get: () => collapsed });
return wrapped;
}
let requests = 0;
const send = debounce(() => {
requests += 1;
}, 40);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const typed = 'javascript';
for (const ch of typed) {
send(ch);
await sleep(8);
}
await sleep(80);
console.log('keystrokes:', typed.length);
console.log('requests sent:', requests);
console.log('never sent because the timer restarted:', send.collapsed);
Change the wait to 0 and to 300. At 0 the two counters match, which is exactly the bug you are removing.
Abort helps, the guard finishes the job
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function api(q, ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(q), ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
}
const abortOnKeystroke = true; // <- flip this
let seq = 0;
let renderedSeq = 0;
let inFlight = null;
async function search(q, ms) {
if (abortOnKeystroke) inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
const mine = (seq += 1);
try {
const answer = await api(q, ms, controller.signal);
if (mine < renderedSeq) {
console.log('guard dropped stale "' + answer + '"');
return;
}
renderedSeq = mine;
console.log('rendered "' + answer + '"');
} catch (err) {
if (err.name === 'AbortError') console.log('aborted the request for "' + q + '"');
else throw err;
}
}
search('jav', 120);
await sleep(5);
search('java', 20);
await sleep(200);
Set abortOnKeystroke to true and watch the older request never come back. Then set it to false and see why you still want the guard.
Exercises
Debounce with cancellation
Write debounce(fn, waitMs, timers) where timers defaults to { set: setTimeout, clear: clearTimeout }. Calling the wrapper schedules fn after waitMs, and any further call before that restarts the wait and replaces the arguments, so fn runs once with the latest arguments. Add cancel() which drops a pending call and clears its timer, flush() which runs a pending call immediately, and a live pending boolean.
A sequence guard for out-of-order responses
Write createLatestGuard() with start(), accept(seq), and live issued and accepted reads. start() hands out the next sequence number, beginning at 1. accept(seq) returns true and records the sequence only when it is a number that was actually issued and is newer than anything already accepted. Anything older, repeated or never issued returns false and changes nothing.
Check yourself
- What does this log?
1— Every call restarts the 100 ms wait, and the gaps are only 90 ms, so the first two are cancelled before they can fire. Only after the final 150 ms of quiet does the function run, once. Debounce waits for silence, so a user who never pauses never triggers it: that is why a search box also wants an explicit Enter path.- Debounce is set to 300 ms and every keystroke aborts the request in flight. Can a stale response still overwrite fresher results?
- Yes, if the response was already delivered when the next keystroke fired, so the abort had nothing left to cancel — Abort cancels work that is still in progress. A response that has already been handed to your
awaitcannot be recalled, and the code after the await will run. That is why the sequence guard sits at the render step, not at the request step. - Which pair correctly matches the tool to the job?
- Debounce for a search box, throttle for scroll position — A search box wants the answer to what you finished typing, so wait for quiet: debounce. A scroll handler wants continuous but bounded feedback while the user keeps scrolling, so allow one call per interval: throttle. Getting these the wrong way round gives you a search that fires constantly and a scroll effect that never updates until you stop.
- A search returns zero results. What should the component do?
- Render a designed empty state naming the query, and announce the count in the live region — Keeping stale results is a lie, and an unexplained blank panel reads as a bug. Zero results is an outcome you design: say what was searched for, suggest what to try, and announce it, because a sighted user sees the list change and a screen reader user does not unless you say so.
Common mistakes
- Importing a debounce and never reading it, so nobody on the team knows whether it restarts or rate limits.
- Recreating the debounced function on every render, which gives every keystroke its own fresh timer and debounces nothing.
- Sending a request for an empty query.
- Treating an
AbortErroras a failure and showing the user an error for something they caused deliberately. - Assuming abort removes the need for a stale guard.
- Rendering whatever arrives last, which is the search race in one sentence.
- Building the results as HTML from the query string, which turns user input into markup.
- Leaving the debounce timer and controllers running after unmount.
Takeaways
- Debounce collapses a burst into one call, and it waits for quiet rather than rate limiting.
- Abort cancels work in flight, and it must clear the underlying timer or request.
- A sequence guard at the render step is the only fix for a response that was already on the way.
- An
AbortErroris a decision, not a failure, so handle it separately. - Empty and error results are states you design, and both need announcing.
- Cleanup means the timer, every controller, and any animation handle.