Cancellation that stops at the first await
Accepting an AbortSignal is a promise to stop. Every await you forget to thread it through is a place where you quietly keep going.
The problem
A dashboard loads three resources in sequence. The function takes an AbortSignal so the caller can cancel when the user navigates away, and it passes that signal to fetch — mostly.
One call was added later, in a hurry, without it.
async function loadDashboard(userId: string, signal: AbortSignal) {
const profile = await fetchJson(`/users/${userId}`, { signal });
const orders = await fetchJson(`/users/${userId}/orders`); // no signal
const invoices = await fetchJson(`/users/${userId}/invoices`, { signal });
return { profile, orders, invoices };
}PREDICT FIRST
The user navigates away while the orders request is in flight, and the caller aborts. What actually happens?
Commit to an answer before you read on. Being wrong here is the part that sticks.
Why it behaves that way
Cancellation is cooperative. An AbortSignal does not interrupt anything; it sets a flag and fires an event, and only code that looks at the flag or listens for the event changes its behaviour. A call that never receives the signal cannot cooperate.
The result is a function whose cancellation latency is set by its longest un-instrumented step. Users perceive that as "the spinner kept going after I clicked away", and servers perceive it as load from clients that have already left.
Threading the signal into every I/O call is the main fix. The second half is checking between steps: after any await, the world may have changed, and signal.throwIfAborted() is the cheap way to say so. It throws the standard AbortError — the same one fetch throws — so callers need only one catch.
Two details that catch people out. Cleanup belongs in finally, and it must be safe to run on the cancelled path, because that path is now reachable at every await. And when you have more than one reason to stop — a user abort plus a timeout — combine them with AbortSignal.any([signal, AbortSignal.timeout(ms)]) rather than racing promises, so the losing timer is discarded with the signal.
async function loadDashboard(userId: string, signal: AbortSignal) {
const profile = await fetchJson(`/users/${userId}`, { signal });
signal.throwIfAborted();
const orders = await fetchJson(`/users/${userId}/orders`, { signal });
signal.throwIfAborted();
const invoices = await fetchJson(`/users/${userId}/invoices`, { signal });
return { profile, orders, invoices };
}
// One reason to stop is rare. Combine them rather than racing them.
async function loadWithTimeout(userId: string, userSignal: AbortSignal) {
const signal = AbortSignal.any([userSignal, AbortSignal.timeout(5_000)]);
return loadDashboard(userId, signal);
}What the change buys you
| Behaviour | Before | After |
|---|---|---|
| Abort during the orders request | Request runs to completion | Request aborts in flight |
| Time from abort to rejection | As long as the slowest un-instrumented call | Immediate |
| Server load after the user leaves | Full round trip still paid | Connection dropped |
| A timeout alongside a user abort | A stray timer outlives the request | One combined signal, discarded together |
Variations worth trying
- Replace the sequential awaits with
Promise.alland check what a rejection does to the requests that are still in flight — nothing, unless they share the signal. - Write a cancellable delay:
delay(ms, signal)that clears its timer and rejects on abort. Every retry loop needs one. - Cancel a stream rather than a request, and make sure the reader is released in
finallyon both the normal and the aborted path. - Combine cancellation with the request-token pattern from stale async results and notice they solve different halves of the same problem: one stops the work, the other decides who may write the result.
TRAIN IT
Cancellation that carries through
Thread a signal through a multi-step load, keep cleanup correct on the cancelled path, and prove it with tests that abort mid-flight.
Open the workout