A search box fires a fetch on every keystroke and renders whatever comes back. Users occasionally see results for a query they have already replaced. What is happening, and how do you fix it?
answer
- completion order is not dispatch order
- a slow early query lands last
- one controller per request, not shared
- abort cannot un-deliver a resolved response
- stamp the response, check before render
basics
~20 sResponses complete out of order, so a slower earlier request can land after a newer one and overwrite it. Cancel the previous request with its own AbortController before starting the next, and additionally drop any response whose query no longer matches the current input.
solid answer
~50 sThat is a response race, not a rendering bug. Each keystroke starts an independent request, the network preserves no ordering, and a slow early query can land after a fast later one and overwrite the newer results. The fix has two halves. First, **cancel**: hold the `AbortController` for the in-flight request, call `abort()` on it as the next keystroke fires, and create a fresh controller for the new request — a signal that has aborted stays aborted, so a shared controller cannot be reused. Catch the resulting rejection and treat it as a cancellation rather than an error to show the user. Second, **guard**: aborting is not retroactive, so a request that had already resolved will still run its handler. Stamp each request with its query, or a monotonic sequence number, and ignore any response that no longer matches the current input. Also abort on teardown so a resolved fetch does not write into a view that is gone.
code
javascript · 19 lineslet controller = null;
let latest = '';
async function search(query, render) {
latest = query;
controller?.abort();
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);
const data = await res.json();
if (query === latest) render(data);
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}go deeper
Know that several requests can be in flight at once and that they do not necessarily finish in the order they were started. Be able to name AbortController as the way to stop one you no longer want.
Explain the per-request AbortController pattern — abort the old one, create a new one, keep it in a variable that outlives the call — and why a single reused controller breaks every request after its first abort.
Demonstrate the second half of the fix: aborting is not retroactive, so carry a query stamp or sequence number and drop responses that no longer match, and abort on teardown as well as on the next keystroke.
Frame it as a policy question. Request lifecycle belongs in a data layer rather than being re-implemented per component; decide where cancellation lives, what the last-write-wins guard is keyed on, and how debouncing, cancellation and caching divide the job between them.
## The failure Say the user types `ca`, then `cat`. Two requests go out, roughly 100 ms apart. The first hits a cold cache and takes 900 ms; the second is served from a warm one and takes 120 ms. `cat`'s results render first and look correct. Then `ca`'s results arrive and overwrite them. The user is now looking at results for a query that no longer exists in the box, and the display corrects itself only on the next keystroke. The bug is intermittent, is invisible on a fast local network, and is one of the most common real defects in typeahead UIs. The root cause is simply that **completion order is not dispatch order**. Nothing in the platform promises that two independent requests finish in the order they were started; server-side variance, cache hits, connection reuse and packet loss all reorder them freely. ## Half one: cancel the previous request ```js let controller = null; function search(query) { controller?.abort(); // stop the previous one controller = new AbortController(); // a fresh one for this request return fetch('/api/search?q=' + encodeURIComponent(query), { signal: controller.signal, }); } ``` The controller must live **outside** the function so it survives between keystrokes — a controller created inside and never stored cancels nothing. And it must be a **new** controller each time: `AbortSignal` is single-use, so a signal that has already aborted keeps `aborted === true` forever, and passing it to a later `fetch()` makes that request reject immediately without ever going out. Cancelling gives you three things: the stale response never arrives to overwrite anything, the browser stops downloading a payload nobody wants, and — if the server honours the disconnect — work stops server-side too. ## Handling the rejection Aborting a fetch makes it reject. That rejection is expected, and it is not a failure to surface: ```js try { const res = await search(query); render(await res.json()); } catch (err) { if (err.name !== 'AbortError') throw err; // real failures still bubble } ``` The classic bug at this step is a `catch` that flips the UI into an error state on every keystroke, because each new keystroke cancels the previous request and the cancellation is reported as if the server had failed. ## Half two: guard, because abort is not retroactive Cancellation is a race of its own. If the previous response had already resolved when `abort()` was called, aborting does nothing to it — the data is delivered and its handler is already queued to run. Body parsing widens the window further: `fetch()` resolves at the headers, and the `await response.json()` that follows is a second suspension point in which the state can change underneath you. So keep a stamp: ```js let latest = ''; // ... latest = query; const data = await res.json(); if (query !== latest) return; // a newer query has superseded this one render(data); ``` A monotonically increasing sequence number works identically and is better when the same query can legitimately be re-issued. Either way the rule is: **the response identifies itself, and the consumer decides whether it is still wanted.** Cancellation is the optimisation; the guard is the correctness. ## Teardown The same controller should be aborted when the widget goes away — the user navigates, the panel closes, the component unmounts. Otherwise an in-flight response resolves into a handler that writes to state nobody is watching, keeps the parsed payload and its closure alive, and in some frameworks produces a warning or an outright error. ## What does not fix it Debouncing reduces the number of requests, which is worth doing, but it does not order them: two requests that both survive the debounce race exactly as before. A per-request delay or an artificial `Promise` ordering does not help either, because the problem is not your code's ordering — it is the network's. Only cancel-plus-guard closes it.
- Does debouncing the input remove the need for cancellation?No. Debouncing cuts the number of requests, which saves bandwidth and server load, but it does not order the ones that still go out. As soon as two requests survive the debounce window — a pause, then more typing — they race exactly as before, and the slower earlier one can still land last. Debounce is a cost optimisation; cancel-plus-guard is the correctness fix.
- Why abort on teardown instead of just ignoring the result?Ignoring it still pays for it. The download continues, the body is still parsed, and the closure holding your handler and its captured state stays alive until it resolves. Aborting releases the transfer immediately and prevents a resolved response writing into a view that no longer exists — which in most frameworks is at best a warning and at worst a crash.
- How would you handle this when the same query can legitimately be issued twice?Switch from comparing the query string to a monotonic sequence number: increment a counter per dispatch, capture it with the request, and render only if the captured value still equals the latest. That way an identical re-issued query is not mistaken for the stale earlier one, and the guard stays a simple integer comparison rather than a deep equality check on request parameters.
saying these in an interview costs you the question
- Assumes responses arrive in the order requested
- Reuses a single AbortController across keystrokes
- Shows AbortError to the user as a request failure
- Thinks debouncing alone removes the race
- Believes abort() can un-deliver a resolved response