Curriculum Series

AbortController and how it composes with fetch + ReadableStream

AbortController and how it composes with fetch + ReadableStream

Cancellation is one of those things that seems trivial — "I want to stop the request" — and then you read the spec and realize there are five places it can fail. Most candidates handle the easy case (cancel before the response arrives) and miss the hard case (cancel mid-stream while you're reading tokens). Let me walk you through the whole pipeline so you know exactly which signal flows where.

The primitive: AbortController and AbortSignal

AbortController is a tiny object with one method (abort()) and one property (signal). The signal is what you pass into APIs to give them a way to know they should stop. Code that initiates the cancel calls controller.abort(). Code that needs to be cancelable listens to signal.

JS
1const controller = new AbortController();
2const signal = controller.signal;
3
4signal.addEventListener('abort', () => {
5 console.log('aborted!', signal.reason);
6});
7
8controller.abort('user pressed Stop');
9// → "aborted! user pressed Stop"

The signal can be passed to any function that accepts one. Built-in support: fetch(), addEventListener(), setTimeout (via the new AbortSignal.timeout() helper), Response.json(), crypto.subtle, and node:fs operations. You can also wire your own functions to take a signal — the convention is documented in AbortSignal.any([signal1, signal2]) for combining signals.

That's the foundation. Now the real question: what does abort actually do when you're streaming?

Stage 1: abort before the response arrives

Easy case. The fetch hasn't returned headers yet. Calling controller.abort():

  1. Cancels the underlying network request (the browser sends an HTTP RST or just drops the socket).
  2. Causes the fetch() promise to reject with a DOMException named AbortError.
JS
1const controller = new AbortController();
2const promise = fetch('/api/generate', { signal: controller.signal });
3controller.abort();
4try { await promise; } catch (e) {
5 console.log(e.name); // "AbortError"
6}

Most candidates stop here. The interesting parts haven't started yet.

Stage 2: abort after headers, before body

Headers arrived. You have a Response object. Now you call response.json() or response.text() — and you abort. What happens?

response.json() reads the body. The signal you passed to fetch() is also threaded through to the body reader. So calling abort() cancels the body read in flight, and response.json() rejects with AbortError.

This is where it gets subtle: once you have a Response object, you don't pass a new signal to .json() or .text() — they inherit the original one. If you want to abort the body read with a different condition, you'd combine signals using AbortSignal.any([originalSignal, newSignal]).

Stage 3: abort mid-stream — the case people get wrong

Now we're in the AI-streaming sweet spot. You've started reading from response.body.getReader() and you're pulling chunks in a loop:

JS
1const res = await fetch('/api/generate', { signal: controller.signal });
2const reader = res.body.getReader();
3const decoder = new TextDecoder();
4
5while (true) {
6 const { value, done } = await reader.read();
7 if (done) break;
8 setMessage(prev => prev + decoder.decode(value, { stream: true }));
9}

User clicks Stop. controller.abort() fires. What happens?

The behavior is: reader.read() rejects with AbortError. The underlying TCP connection is torn down. Your loop's next iteration throws.

But — here's the catch — you must wrap the loop in try/catch. If you don't, the error propagates as an unhandled rejection, and worse, the reader stays in a "locked" state on the response body. You can't release it cleanly. The senior pattern is:

JS
1const reader = res.body.getReader();
2const decoder = new TextDecoder();
3
4try {
5 while (true) {
6 const { value, done } = await reader.read();
7 if (done) break;
8 setMessage(prev => prev + decoder.decode(value, { stream: true }));
9 }
10} catch (err) {
11 if (err.name === 'AbortError') {
12 // Expected. The user hit Stop.
13 } else {
14 throw err;
15 }
16} finally {
17 reader.releaseLock(); // optional but good hygiene
18}

The finally block matters because, if the reader stays locked, you can't reuse the response body. Less common, but worth knowing.

Stage 4: abort and React unmount

This is the version every AI chat UI gets wrong on first try. The component unmounts (user navigates away) while a stream is in flight. Without abort wiring, your stream keeps running, fires setMessage on an unmounted component, and you get the "Can't perform a React state update on an unmounted component" warning — or in React 18+, a leak that just silently keeps running until the response ends naturally.

The pattern:

JS
1useEffect(() => {
2 const controller = new AbortController();
3 startStreaming(controller.signal); // your function takes the signal
4 return () => controller.abort('component unmount');
5}, []);

Two things to notice. First, controller.abort('component unmount') lets you pass a reasonsignal.reason will be 'component unmount', which is incredibly useful for logging vs. user-initiated cancels. Second, this works because all the network and stream APIs in the chain (fetch, reader.read(), decoder.decode) accept or inherit the signal.

Composing multiple signals

Real apps have multiple cancel conditions. Imagine: user clicks Stop, OR component unmounts, OR a 30-second timeout fires. You want any of these to cancel the request. That's AbortSignal.any():

JS
1const userController = new AbortController();
2const unmountController = new AbortController();
3const timeout = AbortSignal.timeout(30_000);
4
5const combined = AbortSignal.any([
6 userController.signal,
7 unmountController.signal,
8 timeout,
9]);
10
11fetch('/api/generate', { signal: combined });

AbortSignal.any() returns a signal that aborts when any of the input signals abort. AbortSignal.timeout(ms) is a static helper that creates a signal which auto-aborts after ms. Browser support is broad — Chrome 116+, Safari 17.4+, Firefox 124+. For older targets, you write a polyfill that listens to each input signal and aborts a controller.

What interviewers probe

Senior interviewers will push on three things:

  1. "What if the user hits Stop, then immediately re-asks?" — you need a new AbortController per request. Reusing one that's already aborted means the new fetch starts in an aborted state. The pattern is controllerRef.current = new AbortController() at the top of every send.

  2. "Does AbortController stop the server?" — partly. The TCP RST tells the server the client is gone. A well-behaved server detects this (Node's req.on('close', ...), Django's request.is_disconnected, etc.) and stops generating. A naive server keeps generating tokens you'll never receive, which costs you money on the LLM API. This is why production AI apps have backend abort handlers, not just client-side ones.

  3. "What's the difference between aborting and just .return()-ing the reader?"reader.cancel() releases the reader and signals the underlying source to stop, but it doesn't tear down the TCP connection. controller.abort() does both. For a clean cancel, abort the controller — the reader will see it and unwind.

The core mental model: AbortSignal is one signal that flows through every layer. Network, stream, decoder, your custom functions. If your code respects the signal, cancellation just works. If any layer ignores it, you have a leak.

Finished reading?

Mark this topic as solved to track your progress.