Lesson 21 of 37
Cancelling Requests and Handling Stale Results
AbortController, AbortSignal.timeout, AbortSignal.any — and the race-safety pattern for making sure the UI shows the latest result, not whichever fetch happened to finish last.
The fetching-data lesson earlier in this track covered the happy path:
call fetch(url), await the response, parse the body, show it. That's
enough for a one-off load. Real applications need two more things that
lesson deliberately deferred: a way to cancel an in-flight request,
and a way to make sure the response the UI acts on is the response to
the latest request, not whichever fetch happened to finish last.
Both problems have one modern answer: AbortController and the
AbortSignal family of APIs.
AbortController: how cancellation actually works
Every fetch accepts a signal option, which is the listener side of
a cancel. You create the two sides with AbortController:
const controller = new AbortController();
const promise = fetch("/api/users", { signal: controller.signal });
// Later, somewhere else, when you no longer want the result:
controller.abort();What happens on .abort():
- The browser stops reading from the network.
- The
fetchPromise rejects with aDOMExceptionwhosenameis'AbortError'. - Any
response.json()or body-stream read already in progress on that response rejects the same way.
That rejection is how you tell "I cancelled this on purpose" apart from "the network failed":
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err.name === "AbortError") return; // cancelled on purpose
throw err; // real failure
}The "return nothing" in the abort branch is deliberate: if you cancelled the request, the caller isn't waiting for an answer anymore. Throwing would just surface a cancellation as an error.
AbortSignal.timeout: the self-cleaning timeout
The classic "cancel this request after 5 seconds" pattern used to look like this:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
return await res.json();
} catch (err) {
clearTimeout(timer);
throw err;
}Correct, but noisy. If you forget the clearTimeout on the happy
path, the controller sits around waiting for a timer that still
fires harmlessly. If you forget it on the error path, same thing.
AbortSignal.timeout() is the one-liner that replaces all of it:
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });The signal fires automatically after 5000ms, the fetch rejects with
a TimeoutError (name 'TimeoutError', same shape as AbortError),
and there's no timer to clean up because the browser is doing the
bookkeeping. Reach for this whenever you'd otherwise set up a
one-off setTimeout to abort.
AbortSignal.any: composing multiple reasons to cancel
Sometimes a request has more than one reason to be cancelled:
- The user navigated away.
- The request is taking too long.
- A parent component unmounted.
- The user pressed Escape.
Each of those might have its own signal. AbortSignal.any([...])
combines them: the returned signal fires as soon as any of the
inputs fires.
const timeout = AbortSignal.timeout(10_000);
const signal = AbortSignal.any([effectSignal, timeout]);
const res = await fetch(url, { signal });Without it, you'd wire up multiple addEventListener("abort", ...)
listeners and manage them yourself. With it, composition is one line.
The stale-result race
The second problem — making sure the UI shows the latest result — is the one that bites more quietly than cancellation. Picture a search input:
input.addEventListener("input", async () => {
const query = input.value;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const results = await res.json();
renderResults(results);
});The user types "react" quickly: five keystrokes, five fetches.
Network latency is not uniform — a request for rea might finish
after a request for reac. The last renderResults call wins,
which means the UI can display the results for rea even though
the user is now on react. This is the stale-result race, and
it's one of the most common invisible bugs in JavaScript UI code.
Fix 1: cancel the previous request
Cancellation is the preferred fix because it also saves work. Keep a controller alive, abort it before firing the next request:
let active = null;
input.addEventListener("input", async () => {
active?.abort(); // cancel the previous one
active = new AbortController();
const query = input.value;
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: active.signal,
});
const results = await res.json();
renderResults(results);
} catch (err) {
if (err.name !== "AbortError") throw err;
}
});Now only the latest fetch can reach renderResults. The earlier
ones rejected the moment the next keystroke fired.
Fix 2: capture and compare
Sometimes cancellation isn't an option — maybe you're waiting on a
computation that can't be aborted, or you're reading from a source
without AbortSignal support. Fall back to capturing the state
you're racing against:
let latest = 0;
input.addEventListener("input", async () => {
const token = ++latest;
const query = input.value;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const results = await res.json();
if (token !== latest) return; // a newer request has started; ignore this one
renderResults(results);
});The monotonically-increasing latest is the tell: when a response
arrives, its token has to still equal the current latest or it's
been superseded. This pattern composes cleanly with libraries that
don't speak AbortSignal and is the ground truth under what React's
useEffect cleanup, SWR's isLagging, and TanStack Query's
queryKey versioning are all solving at a higher level.
Putting it together: a reusable search hook
The real-world shape of all this, in one function:
async function search(query, { signal }) {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
});
if (!res.ok) throw new Error(`search failed: ${res.status}`);
return res.json();
}
// Call site:
let active = null;
async function onInput(query) {
active?.abort();
active = new AbortController();
try {
renderResults(await search(query, { signal: active.signal }));
} catch (err) {
if (err.name === "AbortError" || err.name === "TimeoutError") return;
throw err;
}
}Every piece of this is doing one job: the timeout caps the worst case, the controller cancels when the user types again, and the caller only paints the result for the latest in-flight request.
What to remember
AbortController+signalcancels an in-flight fetch. The Promise rejects withname === "AbortError", which is what you check for in acatch.AbortSignal.timeout(ms)is the modern one-liner for "cancel this request after ms milliseconds" — no timer to clean up, rejects withname === "TimeoutError".AbortSignal.any([signals])combines multiple reasons to cancel — unmount, user navigation, timeout — into one signal.- Network ordering is not guaranteed. A later request can finish before an earlier one, so the UI can show a stale result unless you defend against it.
- Prefer cancellation (
controller.abort()before firing the next request). Fall back to a monotonic token or captured state when cancellation isn't possible. - The reusable shape: wrap your fetch helper to accept a
signal, pass in a composed signal at the call site, cancel on the next invocation.
Check yourself
4 questions · pass 3/4 to unlock Scope and Closures
1.What does calling
controller.abort()actually do to an in-flight fetch?2.What is
AbortSignal.timeout(5000)and why is it easier than wiring up asetTimeoutplus a controller by hand?3.A search input fires a fetch on every keystroke. The user types 'react'; before the response for 'react' arrives, a response for 'reac' arrives *later* because of network timing. Without cancellation, what can go wrong, and what's the fix?
4.What does
AbortSignal.any([a, b, c])do, and when is it the right tool?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.