26 min read

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.

TermMeaning
AbortControllerThe object that sends cancellation: controller.abort(reason).
AbortSignalThe object that receives it: signal.aborted, signal.reason, the 'abort' event. You pass the signal around, and keep the controller.
ReasonThe value passed to abort(reason). Code that stops because of the signal should reject with it.
Cooperative cancellationWork stops only if it checks the signal. Nothing is stopped by force.
Abort raceThe abort arrives after the work has already finished, or while its result is being delivered.
Timeout leakA timeout that stops the waiting but not the work, or a timer left running after the work finished.
CleanupUndoing what you started: clearTimeout, removeEventListener, unsubscribe, close.
DisposableAn object with a [Symbol.dispose]() method, which a using declaration calls at the end of a block.

Part 1: AbortController and AbortSignal

js
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:

BehaviourDetail
Default reasoncontroller.abort() with no argument sets signal.reason to a DOMException named 'AbortError'.
Custom reasoncontroller.abort(err) sets signal.reason to exactly err. Use it to say why: timeout, user cancel, shutdown.
Abort happens onceA second abort() is ignored. The reason stays the first one.
The event fires onceA 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

HelperGives youUse
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 reasonCombining "the caller cancelled" with "our deadline passed".
AbortSignal.abort(reason)An already-aborted signalTests, and returning early.
js
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.

js
// 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 happensfetch rejects withEffect on the connection
Before the response headerssignal.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 readClosed mid-body
After the body has been fully readNothing: the promise has already settledNothing 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:

  1. Check at the start. signal?.throwIfAborted(): an already-aborted signal will never fire its event.
  2. Pass it to everything you await that takes one: fetch, sleep, child calls.
  3. 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.
  4. Listen for abort to stop what can't take a signal: clear a timer, destroy a socket, kill a child process.
  5. Reject with signal.reason, so callers can tell a timeout from a cancel from a real failure.
  6. Remove your abort listener when you finish normally. Otherwise a long-lived signal collects one listener per call (Part 6).
js
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.

js
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.

#
Component
Request
Signal
1
effect runs, fetch(id = 1)
sent
not aborted
1 / 6

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.

Loading simulation…

Part 5: Timeouts That Don't Cancel Anything

The most common timeout in code review:

js
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:

code
 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
#
Caller
callLLM
Timer
1
Promise.race starts
request sent
30 s timer set
1 / 7

The 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 after ms. If the signal aborts first, it clears its timer and rejects with signal.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 like promise if promise settles first. It rejects with a DOMException named 'TimeoutError' after ms, or with signal.reason if signal aborts first. Whichever way it ends, it clears its timer and removes its listener. A late rejection of promise must not become an unhandled rejection.
  • Because a promise cannot be stopped, also write runWithTimeout(task, ms, signal), which takes a function (signal) => promise and passes it a signal that aborts on the timeout or on the caller's signal.
Reference solution
js
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.

js
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. TimeoutError vs signal.reason vs the work's own error: callers retry on the first, stop on the second, and inspect the third.
  • The honest limit. withTimeout stops your waiting, not the work. Say so, and offer runWithTimeout, or better, pass AbortSignal.timeout(ms) straight to fetch.

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 createdUndo withIf you forget
setTimeoutclearTimeout(id)The callback runs late, on stale data; Node's exit waits for it
setIntervalclearInterval(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 readerreader.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):

js
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:

js
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.

js
// 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:

jsx
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]().

js
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:

  1. The browser stops. The page aborts its fetch (or the tab closes and the connection drops).
  2. Your server notices. Listen on the response: res.on('close', …) and check res.writableFinished. Do not listen on req. For a POST, Node emits the request's 'close' as soon as the body has been read, not when the client leaves. Verified on Node 22: with a POST, req 'close' fired at 12 ms while the response was sent at 318 ms. Worse, if the body was read before your handler ran (as express.json() does), the request's 'close' has already fired, and your listener never runs at all.
  3. Your server aborts the provider call, by passing the signal into fetch or the SDK's request options.
  4. 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.
js
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;
  }
});
#
Browser
Your server
Provider
1
POST, stream starts
calls provider
generating
1 / 6

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

js
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.

  1. The work is not cancelled. When the timeout wins, provider.complete keeps 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.
  2. The timer leaks when the call fails. If provider.complete rejects, await throws before clearTimeout, and the timer stays pending for up to 30 s.
  3. A late rejection. After the timeout wins, the provider's own rejection is still handled, because Promise.race attached 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:

js
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

jsx
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.

  1. No cleanup. Every change of jobId starts another interval and never stops the old ones. After an admin has opened five jobs, five intervals poll five jobs, and all of them call setJob, so the badge flickers between statuses of different jobs. In development, StrictMode doubles it from the first mount.
  2. Stale responses. Each tick's fetch can resolve after a later one, so an older status can overwrite a newer one.
  3. Overlapping ticks. If the API takes more than 2 s, ticks overlap (Chapter 4).
  4. Unmount. After the component unmounts, the intervals keep polling forever.

Fix.

jsx
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.

js
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.

js
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

js
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.

  1. 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 holds reject, and through it the promise. After a day, the signal holds hundreds of thousands of closures. Node does not warn about listeners on an AbortSignal.
  2. The 'slot' listener is never removed on abort. At shutdown, every pending waiter leaves a listener on queue.
  3. The reason is thrown away. It rejects with a new, generic Error('aborted'), so callers can't tell shutdown apart from anything else.
  4. An already-aborted signal (a call during shutdown) waits forever, because the abort event has already fired.

Fix.

js
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

Self-test: predict each consequence before revealing it.
  • 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

  1. Promise.race( with a timer: is the work given a signal? Is the timer cleared on every path?
  2. Every async function that does I/O: does it accept { signal } and pass it to every awaited call?
  3. catch blocks around abortable work: do they treat AbortError and TimeoutError differently from real failures?
  4. After an await whose result writes state: is there a signal.aborted, token or cleanup check?
  5. setInterval, setTimeout, addEventListener, .on(, .subscribe(: find the matching undo for each one.
  6. addEventListener('abort': is it removed when the work finishes normally? Is signal.aborted checked first?
  7. Every useEffect that fetches, subscribes or schedules: does it return a cleanup? Does it survive StrictMode's double run?
  8. Server routes that call an LLM: res.on('close') with writableFinished, and a signal passed to the provider call.
  9. Hedged or failover calls: is the losing call aborted?
  10. 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.