Chapter 6 — Cancellation and Cleanup
Stopping work nobody wants any more, and leaving nothing running behind you
Part 3 of the async core. Prerequisites: Chapter 4 — async/await Traps (fail-fast Promise.all keeps running) and Chapter 5 — Race Conditions (stale responses, the AbortController fix). For finding leaks in a running process, see Debugging 08 — Inside the Node Process. Practice: the gym, exercises 03-llm-client, 07-event-bus and 08-stream-parser.
How to Read This Chapter
A promise cannot be cancelled. It is a receipt for work that something else is doing. When you stop waiting for it, by a timeout, a Promise.race or an unmount, the work carries on. So cancellation in JavaScript is cooperative: you pass a signal down to the code doing the work, and that code checks it and stops.
The second half of the chapter is the mirror image: everything you start (a timer, a listener, a subscription, a connection) keeps running and keeps memory alive until you stop it. Leaks and "why is this still running?" bugs are cleanup that was never written.
| Term | Meaning |
|---|---|
AbortController | The object that sends cancellation: controller.abort(reason). |
AbortSignal | The object that receives it: signal.aborted, signal.reason, the 'abort' event. You pass the signal around, and keep the controller. |
| Reason | The value passed to abort(reason). Code that stops because of the signal should reject with it. |
| Cooperative cancellation | Work stops only if it checks the signal. Nothing is stopped by force. |
| Abort race | The abort arrives after the work has already finished, or while its result is being delivered. |
| Timeout leak | A timeout that stops the waiting but not the work, or a timer left running after the work finished. |
| Cleanup | Undoing what you started: clearTimeout, removeEventListener, unsubscribe, close. |
| Disposable | An object with a [Symbol.dispose]() method, which a using declaration calls at the end of a block. |
Part 1: AbortController and AbortSignal
const controller = new AbortController();
const { signal } = controller;
signal.aborted; // false
signal.addEventListener('abort', () => console.log('stop:', signal.reason.message), { once: true });
controller.abort(new Error('user navigated away'));
signal.aborted; // true
signal.reason.message; // 'user navigated away'
The rules, each verified on Node 22:
| Behaviour | Detail |
|---|---|
| Default reason | controller.abort() with no argument sets signal.reason to a DOMException named 'AbortError'. |
| Custom reason | controller.abort(err) sets signal.reason to exactly err. Use it to say why: timeout, user cancel, shutdown. |
| Abort happens once | A second abort() is ignored. The reason stays the first one. |
| The event fires once | A listener added after the abort never fires. Always check signal.aborted first. |
signal.throwIfAborted() | Throws signal.reason if aborted, otherwise does nothing. The one-line check at the top of an abortable function. |
Keep the controller private and hand out the signal. Code that receives a signal can watch for cancellation but cannot trigger it. That is the right split: the owner of a request decides when it is cancelled; the code doing the work only obeys.
The static helpers
| Helper | Gives you | Use |
|---|---|---|
AbortSignal.timeout(ms) | A signal that aborts after ms with a DOMException named 'TimeoutError' | Deadlines. Its timer does not keep Node alive. |
AbortSignal.any([a, b, …]) | A signal that aborts when any input aborts, with that input's reason | Combining "the caller cancelled" with "our deadline passed". |
AbortSignal.abort(reason) | An already-aborted signal | Tests, and returning early. |
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(30_000)]);
user.abort(new Error('cancel clicked'));
signal.reason.message; // 'cancel clicked'
Check the name of the reason to tell the cases apart: 'AbortError' for a plain abort, 'TimeoutError' from AbortSignal.timeout, or your own error class for anything else. Do not report a timeout to the user as "cancelled".
Part 2: Passing Signals Through Layers
A signal only helps if it reaches the code that is waiting. In a real request it has to cross four or five layers, and every layer that awaits something must pass the signal down. One layer that forgets breaks the chain for everything below it.
// route: owns the controller
app.post('/api/stories', async (req, res) => {
const controller = new AbortController();
res.on('close', () => {
if (!res.writableFinished) controller.abort(new Error('client disconnected'));
});
const story = await generateStory(req.body.prompt, { signal: controller.signal });
res.json(story);
});
// service: adds its own deadline, passes the combined signal on
async function generateStory(prompt, { signal }) {
const deadline = AbortSignal.any([signal, AbortSignal.timeout(90_000)]);
const outline = await llm.complete(outlinePrompt(prompt), { signal: deadline });
deadline.throwIfAborted(); // don't start step 2 if we were cancelled
return llm.complete(storyPrompt(outline), { signal: deadline });
}
// client: hands the signal to fetch
async function complete(body, { signal }) {
const res = await fetch(PROVIDER_URL, { method: 'POST', body: JSON.stringify(body), signal });
if (!res.ok) throw new ProviderError(res.status);
return res.json(); // the body read is aborted too
}
How fetch reacts to the signal, verified against a local server on Node 22:
| When the abort happens | fetch rejects with | Effect on the connection |
|---|---|---|
| Before the response headers | signal.reason: a TimeoutError for AbortSignal.timeout, your own error for abort(err) | Closed; the server's res 'close' event fires |
After the headers, during res.text() / res.json() | An AbortError from the body read | Closed mid-body |
| After the body has been fully read | Nothing: the promise has already settled | Nothing to close |
Official provider SDKs generally accept a signal in their per-request options. Check the one you use, then pass the signal exactly as you would to fetch.
Part 3: Making Your Own Functions Abortable
Your own async functions should accept { signal } too. The rules:
- Check at the start.
signal?.throwIfAborted(): an already-aborted signal will never fire its event. - Pass it to everything you await that takes one:
fetch,sleep, child calls. - Check between steps of a loop or a multi-step pipeline. A loop over 500 documents should stop after the current one, not finish all 500.
- Listen for abort to stop what can't take a signal: clear a timer, destroy a socket, kill a child process.
- Reject with
signal.reason, so callers can tell a timeout from a cancel from a real failure. - Remove your abort listener when you finish normally. Otherwise a long-lived signal collects one listener per call (Part 6).
async function retry(fn, { attempts = 4, baseMs = 250, signal } = {}) {
for (let i = 0; ; i++) {
signal?.throwIfAborted();
try {
return await fn({ signal });
} catch (err) {
if (signal?.aborted || i === attempts - 1 || !isRetryable(err)) throw err;
const delay = Math.random() * baseMs * 2 ** i; // full jitter (Chapter 7)
await sleep(delay, { signal }); // a cancel during the wait stops the retries
}
}
}
Node's own setTimeout from node:timers/promises takes a signal (await setTimeout(ms, undefined, { signal })). It rejects with an AbortError and puts your reason in err.cause, not in the error itself. That is a reason to write your own sleep that rejects with signal.reason directly, which is the build below.
Part 4: The Abort Race
Cancellation is a message, and messages can arrive late. The abort race: the abort arrives after the work has completed, or after the result is on its way back to you.
const controller = new AbortController();
const work = Promise.resolve({ rows: 3 }); // the request has already finished
async function load(signal) {
const data = await work;
console.log('continuation runs; signal.aborted =', signal.aborted);
if (signal.aborted) return; // the guard you need
setRows(data);
}
load(controller.signal);
controller.abort(); // same tick, after the work completed
// prints: continuation runs; signal.aborted = true
The awaited promise was already fulfilled, so the continuation is queued as a microtask. abort() runs first, synchronously, but it cannot un-queue the continuation. Nothing inside fetch is listening any more, because the request is done. The continuation runs anyway, with a value nobody wants.
fetch(id = 1)The race also runs the other way, and that direction costs money. Aborting a request does not undo what the server already did. If you abort a POST /charge after the server committed the charge but before the response arrived, the user was charged and your code thinks it failed. Cancellation is not a rollback. For anything with a side effect, pair it with an idempotency key so the user can safely retry (Chapter 5).
The stale-search tab of the race lab has an AbortController toggle. Turn it on and give the first request a long latency: the stale response no longer reaches the screen, and its lane stops early.
Part 5: Timeouts That Don't Cancel Anything
The most common timeout in code review:
function timeout(ms) {
return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}
const answer = await Promise.race([callLLM(prompt), timeout(30_000)]);
It has two leaks, one for each outcome of the race. Measured on Node 22 with a 500 ms "LLM call" against a 100 ms timeout, then a 50 ms call against a 1500 ms timeout:
100 caller gave up: timeout
153 LLM call (50 ms) finished: tokens generated and billed
153 fast call: answer
503 LLM call (500 ms) finished: tokens generated and billed
1604 process exits
Promise.race startsThe fix needs both halves: a signal that stops the work, and a timer that is cleared when the work finishes. With AbortSignal.timeout, both come for free: fetch(url, { signal: AbortSignal.timeout(30_000) }) stops the request, and the signal's timer does not keep Node alive. When the work is something you only have a promise for, you need the build below.
Build: An Abortable sleep and a Proper withTimeout
Spec.
sleep(ms, { signal })resolves afterms. If the signal aborts first, it clears its timer and rejects withsignal.reason. An already-aborted signal rejects at once and starts no timer. When it resolves normally, it removes its abort listener.withTimeout(promise, ms, signal)settles likepromiseifpromisesettles first. It rejects with aDOMExceptionnamed'TimeoutError'afterms, or withsignal.reasonifsignalaborts first. Whichever way it ends, it clears its timer and removes its listener. A late rejection ofpromisemust not become an unhandled rejection.- Because a promise cannot be stopped, also write
runWithTimeout(task, ms, signal), which takes a function(signal) => promiseand passes it a signal that aborts on the timeout or on the caller's signal.
Reference solution
export function sleep(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export function withTimeout(promise, ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
Promise.resolve(promise).catch(() => {}); // we will never read it; don't let it go unhandled
return reject(signal.reason);
}
const timer = setTimeout(() => {
cleanup();
reject(new DOMException(`Timed out after ${ms} ms`, 'TimeoutError'));
}, ms);
const onAbort = () => {
cleanup();
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
function cleanup() {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
Promise.resolve(promise).then(
(value) => { cleanup(); resolve(value); },
(error) => { cleanup(); reject(error); }, // also "handles" a rejection that comes after the timeout
);
});
}
// The better shape: the caller hands over a function, so the timeout can stop the work.
export async function runWithTimeout(task, ms, signal) {
const timeoutSignal = AbortSignal.timeout(ms);
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
combined.throwIfAborted();
return withTimeout(task(combined), ms, combined);
}
Tests, all 12 passing with node --test on Node 22. The timer checks count the 'Timeout' entries in process.getActiveResourcesInfo(); listener checks use getEventListeners from node:events.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getEventListeners } from 'node:events';
import { sleep, withTimeout, runWithTimeout } from './timeouts.mjs';
const timers = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length;
test('sleep rejects with signal.reason and clears its timer', async () => {
const c = new AbortController();
const before = timers();
const p = sleep(10_000, { signal: c.signal });
assert.equal(timers(), before + 1);
c.abort(new Error('shutdown'));
await assert.rejects(p, /shutdown/);
assert.equal(timers(), before);
});
test('sleep with an already-aborted signal starts no timer', async () => {
const before = timers();
const p = sleep(10_000, { signal: AbortSignal.abort() });
assert.equal(timers(), before);
await assert.rejects(p, { name: 'AbortError' });
});
test('sleep removes its abort listener when it finishes normally', async () => {
const c = new AbortController();
await sleep(5, { signal: c.signal });
assert.equal(getEventListeners(c.signal, 'abort').length, 0);
});
test('withTimeout passes the value through and clears the timer', async () => {
const before = timers();
assert.equal(await withTimeout(sleep(5).then(() => 'ok'), 10_000), 'ok');
assert.equal(timers(), before);
});
test('withTimeout rejects with TimeoutError', async () => {
await assert.rejects(withTimeout(new Promise(() => {}), 20), { name: 'TimeoutError' });
});
test('withTimeout rejects with the abort reason and cleans up', async () => {
const c = new AbortController();
const before = timers();
const p = withTimeout(new Promise(() => {}), 10_000, c.signal);
c.abort(new Error('user cancelled'));
await assert.rejects(p, /user cancelled/);
assert.equal(timers(), before);
assert.equal(getEventListeners(c.signal, 'abort').length, 0);
});
test('already-aborted signal: no unhandled rejection from the input', async () => {
let unhandled = false;
const onUnhandled = () => { unhandled = true; };
process.on('unhandledRejection', onUnhandled);
const late = sleep(5).then(() => { throw new Error('late failure'); });
await assert.rejects(withTimeout(late, 1000, AbortSignal.abort(new Error('gone'))), /gone/);
await sleep(20);
process.off('unhandledRejection', onUnhandled);
assert.equal(unhandled, false);
});
test('runWithTimeout aborts the underlying work', async () => {
let workSawAbort = false;
const task = async (signal) => {
try { await sleep(10_000, { signal }); }
catch (e) { workSawAbort = signal.aborted; throw e; }
};
await assert.rejects(runWithTimeout(task, 20), { name: 'TimeoutError' });
assert.equal(workSawAbort, true);
});
The full suite also covers: a pass-through rejection, a rejection arriving after the timeout (no unhandled rejection), and the caller's signal cancelling the work through runWithTimeout. Removing the .catch(() => {}) line in the already-aborted branch makes its test fail, so that line is doing real work.
Edge cases interviewers check:
- The already-aborted signal. Its
'abort'event will never fire, so a version without the up-front check waits for the full timeout. - Cleanup on every path. Success, failure, timeout and abort must all clear the timer and remove the listener. A version that only cleans up on success leaks on the paths that happen under load.
- The losing promise. After a timeout, the original promise may still reject. Something must have a handler on it, or the process crashes later, far from the cause.
- What is rejected.
TimeoutErrorvssignal.reasonvs the work's own error: callers retry on the first, stop on the second, and inspect the third. - The honest limit.
withTimeoutstops your waiting, not the work. Say so, and offerrunWithTimeout, or better, passAbortSignal.timeout(ms)straight tofetch.
Part 6: Cleaning Up What You Started
Every resource you create has an undo. If the undo never runs, the resource keeps running and keeps everything its callback references alive.
| You created | Undo with | If you forget |
|---|---|---|
setTimeout | clearTimeout(id) | The callback runs late, on stale data; Node's exit waits for it |
setInterval | clearInterval(id) | It runs forever; each copy holds its closure |
el.addEventListener(type, fn) | removeEventListener(type, fn) with the same function, or { signal } | Listeners pile up; each holds its closure |
emitter.on(event, fn) | emitter.off(event, fn) | MaxListenersExceededWarning at 11 listeners, then a slow leak |
signal.addEventListener('abort', fn) | removeEventListener, or addAbortListener(...)[Symbol.dispose]() | A silent leak: Node 22 printed no warning with 100 listeners on one AbortSignal |
A subscription (WebSocket, store, AppState, Redis pub/sub) | unsubscribe(), .remove(), .close() | Messages keep arriving for a screen or request that is gone |
| A stream reader | reader.cancel() / stream.destroy() | The socket stays open; the producer keeps producing (Chapter 9) |
One signal can remove many listeners at once, which turns cleanup into a single call (verified with Node's EventTarget; browsers support it too):
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('keydown', onKey, { signal: controller.signal });
document.addEventListener('visibilitychange', onVisibility, { signal: controller.signal });
// later, one line removes all three
controller.abort();
In Node, events.once(emitter, name, { signal }) rejects with an AbortError and removes its listener when the signal aborts. events.addAbortListener(signal, fn) returns a disposable whose [Symbol.dispose]() removes the listener.
{ once: true } is not cleanup
A listener added with { once: true } is removed only when the event fires. On a long-lived signal that never aborts, such as a server-wide shutdown signal, it is never removed:
const shutdown = new AbortController(); // lives as long as the process
async function handleJob(job) {
shutdown.signal.addEventListener('abort', () => job.release(), { once: true });
await runJob(job);
// no removeEventListener: one listener, and one `job`, kept per job, forever
}
After a million jobs, the signal holds a million closures, and each closure holds its job. Nothing warns you. Remove the listener in a finally, or derive a per-job signal with AbortSignal.any([shutdown.signal, jobController.signal]).
Leaks from closures held by listeners
A listener is a function. A function keeps alive every variable it references, and those variables keep alive whatever they reference. So a listener on a long-lived object pins everything in its closure for as long as it stays registered.
// One instance per WebSocket connection
class LiveDashboard {
constructor(socket) {
this.socket = socket;
this.history = []; // grows to megabytes per session
eventBus.on('job:updated', (job) => { // eventBus lives forever
this.history.push(job);
this.socket.send(JSON.stringify(job));
});
}
}
When the socket closes, nothing removes the listener. eventBus holds the arrow function, the function holds this, and this holds the socket and history. Every dashboard ever opened stays in memory. In a heap snapshot, the retainer path reads EventEmitter → listener array → closure (context) → LiveDashboard → history. Fix: keep the handler in a field, and eventBus.off('job:updated', this.onJob) in socket.on('close'). Finding these in production is covered in Debugging 08.
Part 7: React Effect Cleanup and StrictMode
An effect's cleanup function runs before the effect runs again (because a dependency changed) and on unmount. Anything the effect starts, its cleanup must stop:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
fetch(`/api/jobs/${jobId}`, { signal })
.then((r) => r.json())
.then((job) => { if (!signal.aborted) setJob(job); })
.catch((err) => { if (err.name !== 'AbortError') setError(err); });
const id = setInterval(() => refreshStatus(jobId, { signal }), 5_000);
const unsubscribe = jobEvents.subscribe(jobId, setJob);
window.addEventListener('focus', onFocus, { signal });
return () => {
controller.abort(); // cancels the fetch and removes the focus listener
clearInterval(id);
unsubscribe();
};
}, [jobId]);
In development, React's StrictMode runs every effect, then its cleanup, then the effect again, on mount. This is deliberate. If an effect is safe, running it twice changes nothing you can see. If you see two requests in the network tab, two intervals, or two WebSocket connections, the cleanup is missing or incomplete. Do not "fix" it by turning StrictMode off: the same bugs appear in production whenever the effect re-runs or the component remounts (a route change, a new key).
StrictMode also exposes effects that should not be effects at all. A POST /api/stories/:id/viewed in a mount effect fires twice in development. The double call is telling you the operation is not idempotent. Make it idempotent on the server (an upsert keyed by user and story), or trigger it from an event, not from mounting.
The same rules apply in React Native and Expo: AppState.addEventListener returns a subscription whose .remove() belongs in the cleanup, and so do Keyboard listeners and notification subscriptions.
Part 8: using and Symbol.dispose, Briefly
Explicit resource management adds using declarations. When the block exits, normally or by a throw, the runtime calls [Symbol.dispose]() on each using value in reverse order. await using calls and awaits [Symbol.asyncDispose]().
async function exportReport(path, rows) {
await using file = await fs.promises.open(path, 'w'); // FileHandle has Symbol.asyncDispose
for (const row of rows) await file.write(toCsvLine(row));
} // file.close() is awaited here, even after a throw
Where it runs today: the syntax is in V8 13.x, so in Node 24 and Chrome 134 or later. Node 22 has the symbols but not the syntax: Symbol.dispose and Symbol.asyncDispose exist, FileHandle, timers and addAbortListener's return value implement them, but using x = … is a SyntaxError. TypeScript 5.2+ compiles using down for older runtimes. In a review, using is a good replacement for try/finally cleanup. Note that it fixes the return vs return await problem from Chapter 4 only if the work is awaited inside the block.
Part 9: Cancelling LLM Calls So You Stop Paying
A user starts a 60-second story generation and closes the tab after 5 seconds. Unless something cancels the provider call, it runs to completion and you pay for all of it. With retries and failover, you may pay for several copies.
The chain that has to work, end to end:
- The browser stops. The page aborts its
fetch(or the tab closes and the connection drops). - Your server notices. Listen on the response:
res.on('close', …)and checkres.writableFinished. Do not listen onreq. For aPOST, Node emits the request's'close'as soon as the body has been read, not when the client leaves. Verified on Node 22: with aPOST,req'close'fired at 12 ms while the response was sent at 318 ms. Worse, if the body was read before your handler ran (asexpress.json()does), the request's'close'has already fired, and your listener never runs at all. - Your server aborts the provider call, by passing the signal into
fetchor the SDK's request options. - The provider stops generating. For a streamed response, closing the connection generally stops generation, and you are billed for the tokens produced up to that point. For a non-streamed request, the provider may finish the generation anyway. Check your provider's documentation, and prefer streaming for long generations.
app.post('/api/stories/stream', async (req, res) => {
const controller = new AbortController();
res.on('close', () => {
if (!res.writableFinished) controller.abort(new Error('client disconnected'));
});
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120_000)]);
res.writeHead(200, { 'content-type': 'text/event-stream' });
try {
for await (const token of streamCompletion(req.body.prompt, { signal })) {
res.write(`data: ${JSON.stringify(token)}\n\n`);
}
res.end();
} catch (err) {
if (signal.aborted) {
logger.info({ reason: signal.reason?.message }, 'generation cancelled');
return; // nobody is listening; settle credits for what was used
}
throw err;
}
});
The same idea applies inside your own failover logic: when you race two providers (a hedged request), abort the loser as soon as the winner answers. Without that, every hedged call costs double (gym exercise 03-llm-client). And when a queued generation job is cancelled, the worker needs the same signal plumbing: the job runner owns a controller per job, and cancelling the job aborts it.
Spot the Bug
Drill 1: the provider timeout
export async function completeWithTimeout(provider, prompt, ms = 30_000) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${provider.name} timed out`)), ms);
});
const result = await Promise.race([provider.complete(prompt), timeout]);
clearTimeout(timer);
return result;
}
What's wrong?
Answer
Bugs.
- The work is not cancelled. When the timeout wins,
provider.completekeeps running. The caller fails over to the next provider, so for a while you pay for both. During a provider brownout, every request leaves a 30-second-plus call behind, which also holds sockets. - The timer leaks when the call fails. If
provider.completerejects,awaitthrows beforeclearTimeout, and the timer stays pending for up to 30 s. - A late rejection. After the timeout wins, the provider's own rejection is still handled, because
Promise.raceattached a handler to it. That part is fine. Say so, so the interviewer knows you checked.
Fix. Give the work a signal, and let the signal be the timeout:
export function completeWithTimeout(provider, prompt, { ms = 30_000, signal } = {}) {
const deadline = AbortSignal.any([AbortSignal.timeout(ms), ...(signal ? [signal] : [])]);
return provider.complete(prompt, { signal: deadline }); // fetch rejects with TimeoutError and closes the connection
}
Say it: "Promise.race stops waiting, not working: the timed-out call keeps running and billing, and the timer is only cleared on success."
Drill 2: the job status poller
function JobStatus({ jobId }) {
const [job, setJob] = useState(null);
useEffect(() => {
setInterval(async () => {
const res = await fetch(`/api/jobs/${jobId}`);
setJob(await res.json());
}, 2_000);
}, [jobId]);
return <StatusBadge status={job?.status} />;
}
What's wrong?
Answer
Bugs.
- No cleanup. Every change of
jobIdstarts another interval and never stops the old ones. After an admin has opened five jobs, five intervals poll five jobs, and all of them callsetJob, so the badge flickers between statuses of different jobs. In development, StrictMode doubles it from the first mount. - Stale responses. Each tick's fetch can resolve after a later one, so an older status can overwrite a newer one.
- Overlapping ticks. If the API takes more than 2 s, ticks overlap (Chapter 4).
- Unmount. After the component unmounts, the intervals keep polling forever.
Fix.
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
let timer;
async function tick() {
try {
const res = await fetch(`/api/jobs/${jobId}`, { signal });
const next = await res.json();
if (signal.aborted) return;
setJob(next);
if (next.status === 'running') timer = setTimeout(tick, 2_000); // stop when the job is done
} catch (err) {
if (err.name !== 'AbortError') setError(err);
}
}
tick();
return () => { controller.abort(); clearTimeout(timer); };
}, [jobId]);
Say it: "The effect starts an interval and never clears it, so every job the admin opens adds a poller that writes into the same state."
Drill 3: cancelling on disconnect
Finance reports that the LLM bill did not drop after this change shipped, although many users close the tab mid-generation.
app.use(express.json());
app.post('/api/generate', async (req, res) => {
const controller = new AbortController();
req.on('close', () => controller.abort());
res.writeHead(200, { 'content-type': 'text/event-stream' });
for await (const token of llm.stream(req.body.prompt, { signal: controller.signal })) {
res.write(`data: ${JSON.stringify(token)}\n\n`);
}
res.end();
});
What's wrong?
Answer
Bug. The listener is on the wrong object. req is the incoming body stream. Its 'close' event fires once the body has been consumed. express.json() consumed it before this handler ran, so 'close' has already been emitted and the listener never fires. Verified on Node 22: with the body read before the handler, req.destroyed is already true when the handler starts, and a client abort 250 ms later fires only the response's 'close'. The provider stream runs to the end for every user who leaves.
If the body had not been read yet, the bug would be the opposite: 'close' would fire right after the body arrived, and every generation would be aborted at once.
Fix.
res.on('close', () => {
if (!res.writableFinished) controller.abort(new Error('client disconnected'));
});
Also catch the abort error around the loop, so a cancelled generation is not logged as a 500, and record the tokens actually used for billing.
Say it: "req close means the request body is finished, not that the client left; the response's close event with writableFinished false is the disconnect signal."
Drill 4: an abortable wait
const shutdown = new AbortController();
process.once('SIGTERM', () => shutdown.abort(new Error('shutting down')));
export function waitForSlot(queue) {
return new Promise((resolve, reject) => {
shutdown.signal.addEventListener('abort', () => reject(new Error('aborted')));
queue.once('slot', resolve);
});
}
What's wrong?
Answer
Bugs.
- Listener leak. Every call adds an
'abort'listener to a signal that lives as long as the process, and nothing removes it when the slot arrives. Each listener holdsreject, and through it the promise. After a day, the signal holds hundreds of thousands of closures. Node does not warn about listeners on anAbortSignal. - The
'slot'listener is never removed on abort. At shutdown, every pending waiter leaves a listener onqueue. - The reason is thrown away. It rejects with a new, generic
Error('aborted'), so callers can't tell shutdown apart from anything else. - An already-aborted signal (a call during shutdown) waits forever, because the abort event has already fired.
Fix.
export function waitForSlot(queue, signal = shutdown.signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const onSlot = (slot) => { signal.removeEventListener('abort', onAbort); resolve(slot); };
const onAbort = () => { queue.off('slot', onSlot); reject(signal.reason); };
queue.once('slot', onSlot);
signal.addEventListener('abort', onAbort, { once: true });
});
}
Say it: "Every wait registers on a process-lifetime signal and never unregisters, so it leaks one closure per call; each path has to remove the other path's listener."
The Causal Chains
- If you time out with
Promise.race - If a timeout's timer is not cleared when the work wins
- If one layer in the chain does not pass the signal down
- If the abort arrives after the response has already resolved
- If you listen for
req'close'to detect a disconnect - If an effect starts an interval or subscription without a cleanup
- If a listener on a long-lived emitter or signal is never removed
Review Checklist
Promise.race(with a timer: is the work given a signal? Is the timer cleared on every path?- Every
asyncfunction that does I/O: does it accept{ signal }and pass it to every awaited call? catchblocks around abortable work: do they treatAbortErrorandTimeoutErrordifferently from real failures?- After an
awaitwhose result writes state: is there asignal.aborted, token or cleanup check? setInterval,setTimeout,addEventListener,.on(,.subscribe(: find the matching undo for each one.addEventListener('abort': is it removed when the work finishes normally? Issignal.abortedchecked first?- Every
useEffectthat fetches, subscribes or schedules: does it return a cleanup? Does it survive StrictMode's double run? - Server routes that call an LLM:
res.on('close')withwritableFinished, and a signal passed to the provider call. - Hedged or failover calls: is the losing call aborted?
- Mutating requests that can be aborted: is there an idempotency key, since an abort is not a rollback?
What's Next
You can now stop work cleanly. Chapter 7 — Controlling Concurrency is about deciding how much work to start in the first place: pools, rate limits, backoff with jitter, and the retry storm.