Cancellation & races: AbortController and latest-only requests
- Thread an
AbortSignalthrough your own async functions: accept{ signal }, checkthrowIfAborted(), reject withsignal.reason, and stop the underlying work - Combine a cancel with a deadline using
AbortSignal.any()andAbortSignal.timeout()(naming their support versions), and tell a cancellation, a timeout and a real failure apart - Fix a last-response-wins race with an abort plus a latest-request check, and say what each one covers
- Explain what aborting can't do: un-send a write, order updates on the server, or stop code that ignores the signal
Read
A controller and its signal
FlagPromises have no built-in "cancel". The platform's answer is a pair of objects:
AbortController, the button. Whoever decides to cancel keeps it and callsabort().AbortSignal(controller.signal), the wire. You pass it to the work. The work watches it and stops when it fires.
const controller = new AbortController();
fetch("/api/report", { signal: controller.signal })
.then((res) => res.json())
.then(showReport)
.catch((err) => {
if (err.name === "AbortError") return; // we cancelled it: not a failure
showError(err);
});
cancelButton.onclick = () => controller.abort();
When abort() is called, fetch stops the request and its promise rejects with an AbortError
DOMException. If the response headers have already arrived, reading the body
(res.json(), res.text()) rejects instead.
Four facts about the pair:
- Aborting is one-way and once. The first
abort()setssignal.aborted = trueand fires theabortevent. Later calls do nothing, and the signal stays aborted forever. - So use one controller per operation. A signal that's already aborted makes every new
fetchreject immediately. Create a fresh controller for each request you might cancel. signal.reasonsays why. With no argument it's anAbortErrorDOMException.abort(someValue)makessomeValuethe reason, andfetchrejects with exactly that value.- Cancellation is cooperative. A signal does nothing by itself. It only stops work that
takes it and listens to it.
fetchdoes. Your own async functions must be written to (medium tier).
Threading a signal through your own functions
Flagfetch honours a signal because it was written to. Your own async functions only become
cancellable if you write them the same way. The DOM Standard spells out the contract for any
promise-returning API that can be aborted: take the signal as a signal option, reject
immediately if it's already aborted, and otherwise reject with signal.reason when it aborts.
Pass it down to everything that waits.
async function loadReport(id, { signal } = {}) {
const res = await fetch(`/api/reports/${id}`, { signal });
const report = await res.json(); // also aborted by the same signal
await sleep(250, signal); // your own wait: make it abortable too
signal?.throwIfAborted(); // a checkpoint before expensive CPU work
return summarise(report);
}
Each step that doesn't receive the signal is a hole: an abort during that step goes unnoticed until the next step that does check.
Make your own waits abortable.
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted(); // already aborted: reject right away
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort); // done: stop listening
resolve();
}, ms);
function onAbort() {
clearTimeout(timer); // stop the work itself…
reject(signal.reason); // …and settle with the caller's reason
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
The details carry the weight:
throwIfAborted()first. A listener added to a signal that's already aborted never fires, so without this check the promise would wait out the whole delay.reject(signal.reason), notnew Error("aborted"). The caller chose the reason (anAbortErrorby default, aTimeoutErrorfromAbortSignal.timeout, or their own value). Pass it through unchanged so their checks still work.- Stop the work, not just the promise.
clearTimeout(oraborton an inner request) is the part that saves resources. Rejecting alone just means nobody waits for the result. - Remove the listener when you finish normally. A long-lived signal (one per page, say) would otherwise collect a listener from every call.
Latest-only requests — abort and an identity check, together
FlagA reusable wrapper that makes any signal-taking request "latest only". Each call aborts the one
before it, adds an optional deadline, and reports the outcome as data, so callers never need a
try/catch to tell "superseded" from "failed":
function latestOnly(request, { timeoutMs } = {}) {
let current; // the controller of the newest call
return async function run(...args) {
current?.abort(); // 1. the previous call is stale now
const controller = new AbortController();
current = controller;
const deadline = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
const signal = deadline ? AbortSignal.any([controller.signal, deadline]) : controller.signal;
try {
const value = await request(...args, signal);
if (controller !== current) return { status: "superseded" }; // 2. finished, but too late
return { status: "ok", value };
} catch (error) {
if (controller !== current) return { status: "superseded" }; // 3. aborted by a newer call
if (deadline?.aborted) return { status: "timeout" }; // 4. our deadline passed
return { status: "error", error };
}
};
}
const search = latestOnly(
(q, signal) => fetchJson(`/search?q=${encodeURIComponent(q)}`, { signal }),
{ timeoutMs: 5000 },
);
const outcome = await search("rea");
if (outcome.status === "ok") render(outcome.value);
Why both mechanisms? The abort (1) stops the network work, but it can only stop steps that
take the signal. Anything else in request still runs to completion: a cache lookup, a
third-party SDK call, a JSON.parse of a big payload, an await enrich(data) that ignores the
signal. And an abort can't un-fulfil a promise that has already fulfilled. The identity check (2,
3) is what guarantees that only the newest call's result is ever used, whatever the request
does with its signal.
Why compare controllers instead of checking error.name? "Was this superseded?" is a question
about our bookkeeping, not about the error. A newer call might abort with a custom reason, and a
request may wrap the abort in its own error type. The controller identity answers it exactly.
"Did it time out?" is bookkeeping too: keep the timeout signal and ask whether it aborted (4). A
wrapped error has no TimeoutError name to find.
Superseded is not an error. Return it as its own status, and have the UI ignore it. Shown as an error, every fast typist sees a "Search failed" flash.
Quiz
A search box keeps one AbortController for the whole page and calls controller.abort() before each new search, passing controller.signal to fetch. From the second keystroke on, every search fails instantly. Why?
Your sleep(ms, signal) adds an abort listener that clears the timer and rejects. Called with a signal that's already aborted, it waits the full ms and then resolves. Why?
A client cancels with controller.abort(new Error("superseded")), and its catch ignores errors where err.name === "AbortError". What does the user see when a search is superseded?
Why prefer fetch(url, { signal: AbortSignal.timeout(5000) }) over Promise.race([fetch(url), rejectAfter(5000)])?
A request uses signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]). Which catch logic tells a user cancel, a timeout and a real failure apart, even if someone aborts with a custom reason?
A search aborts the previous request on every keystroke. But results come from sdk.query(q), which takes no signal. What can still go wrong?
A DELETE /projects/42 request is aborted 50 ms after it was sent. What do you know about project 42?
Fill in the blank
Make this request cancellable by the controller.
Flagconst res = await fetch(url, { : controller.signal });
When the signal aborts, stop the timer and reject with whatever reason the caller chose.
Flagsignal.addEventListener("abort", () => { clearTimeout(timer); reject(signal.); }, { once: true });
Give one request two ways to stop: the controller, or a 5-second deadline.
Flagconst signal = AbortSignal.([controller.signal, AbortSignal.(5000)]);
sdk.query can't be cancelled. Make sure only the newest query's results are rendered.
const id = ++latestId; const results = await sdk.query(q); if (id latestId) return; // a newer query has started since render(results);
Show a timeout message, but only for a deadline set with AbortSignal.timeout().
if (err.name === "") showMessage("Search timed out");
Predict the output
const controller = new AbortController();
controller.signal.addEventListener("abort", () => console.log("abort event"));
controller.abort("first");
controller.abort("second");
console.log(controller.signal.aborted, controller.signal.reason);const controller = new AbortController();
const request = fetch("https://api.example.com/search?q=re", { signal: controller.signal });
controller.abort(new Error("superseded"));
try {
await request;
} catch (err) {
console.log(err.name, "|", err.message, "|", err.name === "AbortError");
}let latest = 0;
const shown = [];
async function onQuery(q, ms) {
const id = ++latest;
const results = await new Promise((resolve) => setTimeout(() => resolve(q), ms));
if (id !== latest) return;
shown.push(results);
}
onQuery("r", 90);
onQuery("re", 30);
setTimeout(() => onQuery("rea", 10), 50);
setTimeout(() => console.log(shown), 200);Challenge
Make the search client latest-only
FlagcreateSearchClient() backs a search-as-you-type box. The UI calls search(query) on every
(debounced) change and cancel() when the box is cleared, and renders getState():
{ query, status, results, error }.
search-api.ts is the backend (read-only). Like fetch, it takes an AbortSignal and rejects
with signal.reason when that signal aborts. The client's timeoutMs option defaults to
TIMEOUT_MS from config.ts, which the tests use (the preview passes a longer one).
On a fast connection the client looks fine. On a real one it has four problems:
- Every keystroke's request runs to completion, even after a newer query has replaced it.
- Responses arrive in random order, and whichever arrives last is shown, so the box can say "rea" while showing the results for "r".
- Clearing the box shows "Search failed": the cancellation is reported as an error. Fast typists would see the same flash on every superseded search, once requests are aborted.
- A request that hangs leaves the box on "loading" forever.
Your task
Edit search-client.ts so the tests pass:
- A search shows its results, with
status: "success" - Starting a new search aborts the previous request's signal
- An older response never overwrites a newer search's results, even when the request ignores its signal
- A superseded search is never reported as an error, even when the request rejects with its own error instead of an
AbortError cancel()aborts the request in flight and returns tostatus: "idle"with no error, and stays idle even if that request still resolves- A search that takes longer than
timeoutMs(defaultTIMEOUT_MS) has its request aborted, and ends withstatus: "error"anderror: "Search timed out", even when the request wraps the abort in its own error - A real failure ends with
status: "error"and the failure's own message