CodeStaminaOpen preview

Cancellation · TypeScript · 12 min

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.

Train this

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.

A signal that is threaded through two calls out of three
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.

Cancellation threaded and checked at every step
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

BehaviourBeforeAfter
Abort during the orders requestRequest runs to completionRequest aborts in flight
Time from abort to rejectionAs long as the slowest un-instrumented callImmediate
Server load after the user leavesFull round trip still paidConnection dropped
A timeout alongside a user abortA stray timer outlives the requestOne combined signal, discarded together

Variations worth trying

  1. Replace the sequential awaits with Promise.all and check what a rejection does to the requests that are still in flight — nothing, unless they share the signal.
  2. Write a cancellable delay: delay(ms, signal) that clears its timer and rejects on abort. Every retry loop needs one.
  3. Cancel a stream rather than a request, and make sure the reader is released in finally on both the normal and the aborted path.
  4. 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

JOIN THE WAITLIST

Help shape what we build next.

Tell us where you want to start and what you want to practise. We’ll use your answers to understand demand and email you about relevant program availability.

What would you like to practise?

We store your email and answers for this waitlist. No campaign is being sent now. Contact mo@codestamina.com to ask us to remove your entry.