Skip to content

Cancellation and out-of-order responses

Promises cannot be cancelled, so you abort cooperatively through a signal, or ignore stale responses; either way the server keeps working.

Predict first

Before you read: predict

EXH-583 · ExamHall candidate app. A candidate clicks Q3 → Q4 → Q5 quickly. Q3's saved answer is a long essay, so its response is the slowest. Each fake response takes the delay shown.

What does this print?

Code for this question
// ExamHall candidate app: question navigator, simplified. ESM, Node 24.import { mock } from 'node:test';mock.timers.enable({ apis: ['setTimeout'] });        // virtual clock: no real waiting, no race const DELAY_MS = { Q3: 300, Q4: 100, Q5: 150 };      // Q3 has a long saved essayconst api = {  getAnswer: (q) => new Promise((resolve) => setTimeout(resolve, DELAY_MS[q], { q })),}; let shown = null;async function openQuestion(q) {  const answer = await api.getAnswer(q);  shown = answer.q;  console.log(`paint ${answer.q}`);} openQuestion('Q3');                                   // three fast clicks: Q3 → Q4 → Q5openQuestion('Q4');openQuestion('Q5'); for (let t = 0; t < 400; t += 50) {                   // advance the virtual clock 400 ms  mock.timers.tick(50);  await new Promise((r) => setImmediate(r));          // let continuations run}console.log(`screen shows ${shown}`);
Pick one

The 30-second version

A promise has no cancel: each await resumes when its own response settles, so paints follow arrival order, not click order, and a slow old request can overwrite newer state. Cancellation is cooperative. controller.abort(reason) marks the signal aborted, stores signal.reason and fires abort; only code holding that signal stops. The server is never told.

Promises cannot be cancelled, so you abort cooperatively through a signal, or ignore stale responses; either way the server keeps working.

How it works

Step 1 of 7

Each call to an async function starts its own request and suspends at its own await. Separate calls share nothing, so nothing orders them.

Analogy: A courier dispatch office

A dispatcher takes customer calls and sends a courier out for each. Couriers carry radios, but some leave theirs switched off.

In the analogy In the system
Each call puts a new courier on the road with a job card each openQuestion call starts its own request
Couriers come back in order of distance, not in the order the calls came in continuations run in arrival order (step 2)
The counter shows the last parcel that was handed in shared state holds whatever response settled last
The dispatcher radios "job 17 withdrawn: customer cancelled" controller.abort(reason) fires abort with signal.reason (step 3)
Only a courier with the radio on turns back; one with it off completes the run only code given the signal stops; a function that ignores it runs on (step 4)
The warehouse that is packing the parcel gets no call the server gets no message; its handler keeps working (step 5)

Breaks down when:

  • A courier who turns back brings the parcel home. Abort undoes nothing: a server write that already committed stays committed.
  • A radio call takes time to arrive. abort() runs the abort listeners synchronously, before abort() returns.

Contrasting case: kill -9 on a Linux process. The kernel stops the process whether it cooperates or not, and the process gets no chance to clean up. AbortSignal is the opposite: nothing stops unless the running code checks the signal or listens for it.

In real terms: abort is a one-shot, cooperative notification carried by a signal, not a way to stop a promise from outside.

Examples, run for real

E1 · ExamHall: two fixes for EXH-583 (MR !883)

Status: executed, Node v24.19.0, 2026-09-27, 50 runs, 1 distinct output. Virtual clock (node:test mock timers). ESM.

// EXH-583 fix (MR !883): abort the previous answer load; alternative: a request-id guard. ESM, Node 24.
import { mock } from 'node:test';
mock.timers.enable({ apis: ['setTimeout'] });
const DELAY_MS = { Q3: 300, Q4: 100, Q5: 150 };

const server = {                                      // fake API server: it never sees the abort
  handle(q, reply) { setTimeout(() => { console.log(`  server finished ${q}`); reply({ q }); }, DELAY_MS[q]); },
};
function getAnswer(q, { signal } = {}) {              // a client that honours a signal, like fetch
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();
    signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
    server.handle(q, resolve);                        // resolving after a reject is a no-op
  });
}

let current;                                          // Fix A: AbortController per navigation
async function openWithAbort(q) {
  current?.abort();                                   // no reason given → DOMException AbortError
  const controller = (current = new AbortController());
  try { console.log(`paint ${(await getAnswer(q, { signal: controller.signal })).q}`); }
  catch (err) { if (controller.signal.aborted) console.log(`skip ${q}: ${err.name}`); else throw err; }
}

let latest = 0;                                       // Fix B: request-id guard
async function openWithGuard(q) {
  const id = ++latest;
  const answer = await getAnswer(q);
  if (id !== latest) return console.log(`ignore ${q} (stale, id ${id} of ${latest})`);
  console.log(`paint ${answer.q}`);
}

for (const [label, open] of [['A: abort', openWithAbort], ['B: guard', openWithGuard]]) {
  console.log(`--- ${label}`);
  ['Q3', 'Q4', 'Q5'].forEach((q) => open(q));
  for (let t = 0; t < 400; t += 50) { mock.timers.tick(50); await new Promise((r) => setImmediate(r)); }
}
--- A: abort
skip Q3: AbortError
skip Q4: AbortError
  server finished Q4
  server finished Q5
paint Q5
  server finished Q3
--- B: guard
  server finished Q4
ignore Q4 (stale, id 2 of 3)
  server finished Q5
paint Q5
  server finished Q3
ignore Q3 (stale, id 1 of 3)

Fix A cancels on the client: Q3 and Q4 reject with AbortError the moment the next click aborts them. Fix B lets every request finish and drops the stale results. In both, server finished Q3 still prints: the fake server never saw a cancel (step 5). Fix A's cost: every API in the chain must accept the signal. Fix B's cost: the stale responses still download and still hold connections.

E2 · Catalog (proof domain): the server keeps working after the client aborts

Status: executed, Node v24.19.0 (fetch against a real node:http server on 127.0.0.1), 2026-09-27, 50 runs, 1 distinct output. Lab file, 45 lines, collapsed by default. No timing: in-process handshakes order every step.

// Catalog proof domain: aborting fetch closes the connection; the server's work goes on
// unless the handler listens for the close. Real node:http server on 127.0.0.1. ESM, Node 24.
import http from 'node:http';

const serverLog = [];
let arrived, handled;                                 // per-request handshakes (no timing involved)

async function searchCatalog(q, signal) {             // 3 shard reads; checks the signal between them
  const hits = [];
  for (const shard of ['s1', 's2', 's3']) {
    if (signal?.aborted) { serverLog.push(`  ${q}: stopped before ${shard}`); return hits; }
    await new Promise((r) => setImmediate(r));        // stands in for one shard query
    hits.push(`${q}@${shard}`);
  }
  serverLog.push(`  ${q}: searched all 3 shards`);
  return hits;
}

const server = http.createServer(async (req, res) => {
  const cooperative = req.url.startsWith('/search-coop');
  const gone = new AbortController();                 // server-side signal for "client left"
  const closed = new Promise((r) => res.on('close', r));
  res.on('close', () => { if (!res.writableFinished) gone.abort(); });
  arrived.resolve();
  await closed;                                       // the search is slow: the client leaves first
  const hits = await searchCatalog('lamp', cooperative ? gone.signal : undefined);
  serverLog.push(`  ${req.url}: ${hits.length} hits computed, res.destroyed=${res.destroyed}`);
  handled.resolve();
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;

for (const path of ['/search?q=lamp', '/search-coop?q=lamp']) {
  arrived = Promise.withResolvers();
  handled = Promise.withResolvers();
  const controller = new AbortController();
  const pending = fetch(base + path, { signal: controller.signal });
  await arrived.promise;                              // the server has the request
  controller.abort();
  const err = await pending.catch((e) => e);
  console.log(`client ${path}: ${err.name}, same object as signal.reason: ${err === controller.signal.reason}`);
  await handled.promise;
  console.log(serverLog.splice(0).join('\n'));
}
server.close();
client /search?q=lamp: AbortError, same object as signal.reason: true
  lamp: searched all 3 shards
  /search?q=lamp: 3 hits computed, res.destroyed=true
client /search-coop?q=lamp: AbortError, same object as signal.reason: true
  lamp: stopped before s1
  /search-coop?q=lamp: 0 hits computed, res.destroyed=true

The client gets the same AbortError in both runs. The naive handler searches all three shards for a response nobody will read (res.destroyed=true). The cooperative handler turns the socket's close into its own AbortController and stops before the first shard.

E3 · ExamHall: a time budget plus "the candidate left", with AbortSignal.timeout and AbortSignal.any

Status: executed, Node v24.19.0, 2026-09-27, 50 runs, 1 distinct output. The local server accepts and never replies, so only a signal can end each fetch; the 50 ms timer has no competitor.

// ExamHall: load the ECON-201 paper with a 50 ms budget OR until the candidate leaves the page.
// The local server accepts the request and never answers, so only a signal can end the fetch.
import http from 'node:http';
const server = http.createServer(() => {});            // hangs forever: no timing race to win
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/exams/ECON-201/paper`;

const show = (v) => (typeof v === 'string' ? JSON.stringify(v) : v.name);
async function loadPaper(pageSignal) {
  const signal = AbortSignal.any([pageSignal, AbortSignal.timeout(50)]);
  try { return await fetch(url, { signal }); }
  catch (err) { return `rejected with ${show(err)}; err === signal.reason: ${err === signal.reason}`; }
}

const stay = new AbortController();                     // case 1: the candidate stays; the server hangs
console.log('1', await loadPaper(stay.signal));

const leave = new AbortController();                    // case 2: the candidate leaves at once
const p = loadPaper(leave.signal);
leave.abort('route change: /exams → /home');            // a string reason is kept as-is
console.log('2', await p);

const done = new AbortController();                      // case 3: a signal aborted earlier
done.abort();
console.log('3', await loadPaper(done.signal));

server.closeAllConnections();
server.close();
1 rejected with TimeoutError; err === signal.reason: true
2 rejected with "route change: /exams → /home"; err === signal.reason: true
3 rejected with AbortError; err === signal.reason: true

The combined signal takes the reason of whichever input aborted first (step 6), and fetch rejects with that exact object. Case 2 shows that a reason can be any value; code that checks only err.name sees undefined there.

Picture it

a dispatcher at a desk lifts one blue request card out of a green tray that still holds another blue card, while in the background a courier cycles across the city with a parcel on the rack. It is the section 3 analogy scene. The image does not show which way the courier is heading, so "already on the way" comes from the stem, not the picture.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A chat client closes a room's message stream when the user leaves.

What does this print?

Code for this question
// Chat: leaving a room closes its message stream. ESM, Node 24.const room = new AbortController();room.signal.addEventListener('abort', () => console.log('stream closed:', room.signal.reason));room.abort('left #general');room.abort('left #random');console.log(room.signal.aborted, room.signal.reason);

Question 2 of 6

A buyer switches from EV-1 to EV-2. loadSeatsBuggy receives the signal and the caller aborts it, yet the seat map shows EV-1.

Tap the line that makes the abort do nothing.

Code for this question
// Ticketing: the seat map refreshes when the buyer switches events. ESM, Node 24.import { mock } from 'node:test';mock.timers.enable({ apis: ['setTimeout'] });const api = {  seats: (eventId, { signal } = {}) => new Promise((resolve, reject) => {    signal?.addEventListener('abort', () => reject(signal.reason), { once: true });    setTimeout(resolve, eventId === 'EV-1' ? 300 : 100, `${eventId} seats`);  }),}; async function loadSeatsBuggy(eventId, signal) {  const seats = await api.seats(eventId);               // line A  console.log(`buggy render ${seats}`);                 // line B}async function loadSeatsFixed(eventId, signal) {  const seats = await api.seats(eventId, { signal });  console.log(`fixed render ${seats}`);} for (const load of [loadSeatsBuggy, loadSeatsFixed]) {  const first = new AbortController();  load('EV-1', first.signal).catch((e) => console.log(`${load.name} EV-1: ${e.name}`));  first.abort();                                         // the buyer switches to EV-2  load('EV-2', new AbortController().signal);  for (let t = 0; t < 400; t += 50) { mock.timers.tick(50); await new Promise((r) => setImmediate(r)); }}

Question 3 of 6

Its own screen, shown after PI-2a is answered, and graded as a separate item. Same code.

Which one change stops EV-1 from rendering after the buyer switches?

Pick one

Question 4 of 6

A shopper taps Pay. The client sends POST /charges; the server has received it. The shopper taps Cancel, which calls controller.abort().

Which statement is true?

Pick one

Question 5 of 6

A chat client guards channel switches with a request id. With let latest = 0 at module level, openChannel(ch) runs const id = ++latest; await history(ch); if (id !== latest) return; const members = await roster(ch); show(ch, members);. A user clicks #general at t = 0, #random at 100 and #design at 200 ms. History takes 50, 150 and 30 ms; the roster then takes 300, 20 and 30 ms. The pane shows #design at 260, then #general at 350.

Which one change leaves the pane on #design?

Pick one

Question 6 of 6

A merchant starts a 5-page catalog export, exportCatalog(signal), whose body is await loadFonts(); signal.addEventListener('abort', () => job.stop()); job.start();. Loading fonts takes 20 ms, and the merchant clicks Cancel, which calls controller.abort(), at 10 ms. All 5 pages still export.

Which is the bug?

Pick one

Go deeper

Pro tip

TIP-1 · Tell a timeout from a user abort by the reason, not by the rejection alone.

  • Trick: in the catch, branch on signal.reason?.name: TimeoutError means the budget ran out (retry or show "slow network"); AbortError means someone cancelled (stay silent).
  • When it helps: a combined AbortSignal.any signal (step 6), so that aborts are not logged as errors.
  • Case 1 prints TimeoutError and case 3 prints AbortError. Case 2 prints a string reason, so guard against reasons with no name. Status: RAN, Node 24.19.0.

TIP-2 · node:test mock timers do not drive AbortSignal.timeout on Node 24.19.

  • Trick: in tests, inject the signal, or abort a controller from a mocked setTimeout with new DOMException('…', 'TimeoutError'), instead of calling AbortSignal.timeout directly.
  • When it helps: a timeout test that hangs, or that needs real waiting to pass.
  • Status: RAN, Node 24.19.0.
Source

Also fetched: MDN AbortController (https://developer.mozilla.org/en-US/docs/Web/API/AbortController), which exists but does not document the reason parameter on its main page, so it is not the primary citation for step 3.

Checked against the tools and docs as of 2026-09-27.