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?
// 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}`);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
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 theabortlisteners synchronously, beforeabort()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

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?
// 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.
// 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?
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?
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?
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?
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 onsignal.reason?.name:TimeoutErrormeans the budget ran out (retry or show "slow network");AbortErrormeans someone cancelled (stay silent). - When it helps: a combined
AbortSignal.anysignal (step 6), so that aborts are not logged as errors. - Case 1 prints
TimeoutErrorand case 3 printsAbortError. Case 2 prints a string reason, so guard against reasons with noname. 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
setTimeoutwithnew DOMException('…', 'TimeoutError'), instead of callingAbortSignal.timeoutdirectly. - When it helps: a timeout test that hangs, or that needs real waiting to pass.
- Status: RAN, Node 24.19.0.
Source
- MDN, AbortSignal: timeout() static method: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static. Valid as of 2026-09-27: fetched and confirmed; it states the
TimeoutErrorDOMExceptionand points toAbortSignal.any(). - Valid as of 2026-09-27: fetched and confirmed; it defines
AbortController,AbortSignal,any()andtimeout().
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.