Topic 3.1
The Stop Button and AbortController
In one line
Stopping an answer means telling the browser to close the connection, which tells your server to stop asking the model. AbortController does it; the part people miss is telling a deliberate stop apart from a real failure, and making sure an old request never writes into a new question.
Think of it like this
Cancelling a takeaway order. You can ring the restaurant and say "please stop, I don't want it", or you can stop answering the door and say nothing. In the second case the kitchen keeps cooking and you still pay. A Stop button must be the phone call, not the closed door: it has to reach all the way to the model, or tokens are still being produced, and paid for, for nobody.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- AbortController
- An object with one job: to cancel things. You create one per request and call
abort()when the request should stop. - AbortSignal
- The "cancelled or not" flag of a controller (
controller.signal). You give it tofetch, which listens to it. - AbortError
- The error a cancelled
fetchor stream read throws. Itsnameis"AbortError". It is expected, not a failure. - Abort reason
- An optional value you pass to
abort(reason). The rejection then carries that value, so you can tell why a request was stopped. - req.signal
- On your server, the signal that fires when the browser hangs up. Forward it to the model call so generation stops too.
- Cleanup function
- The function an effect returns. React runs it when the component leaves the page or the effect re-runs, which is the place to abort.
- Stale response
- A reply that belongs to an old question but arrives after a newer question was asked. It must never be shown.
- In flight
- Sent but not finished. Pieces of a cancelled request can still be "in flight" for a moment.
Step by step
01One controller per request
Create the controller when the request starts, keep it where the Stop button can reach it (a useRef in React), and pass its signal to fetch. The Stop button calls abort().
const controller = new AbortController();
const res = await fetch("/api/chat", { method: "POST", body, signal: controller.signal });
// ... reading the stream ...
stopButton.onclick = () => controller.abort();02What the Stop press sets in motion
Follow one press from the browser to the model. The first live example prints a line on the server side when the hang-up arrives: that is req.signal firing.
03Tell Stop apart from failure
The catch block gets every kind of error. Branch on the name: an AbortError means the user (or your own code) stopped the request on purpose, so go to stopped quietly. Anything else is a failure and gets a real message.
The second example tries seven different things and shows what fetch does for each. Notice that a 429 does not throw at all, and that an already-aborted signal rejects before any request is sent.
04Old request, new question
The third example asks a second question as soon as the first answer has shown two pieces (the trigger is an event of the stream, so the result is the same on every run). Without cancelling, both answers append to one string and their words interleave. With the fix, the new question first calls abort() on the old controller, and every write checks signal.aborted.
05Leave the screen, stop the request
In React, return () => controller.abort() from the effect that starts the request. The fourth example removes the component while it streams: with the cleanup you see the server notice and the request end with AbortError; without it the request runs to the end, unseen.
06Where it stops, and where it cannot
Stopping saves the tokens after the stop. It cannot take back tokens already produced and it cannot undo something a tool already did (Phase 8). And it only reaches the model if every hop forwards the signal: browser to your route, your route to the provider.
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch(PROVIDER_URL, {
method: "POST",
headers: { authorization: "Bearer " + process.env.PROVIDER_API_KEY, "content-type": "application/json" },
body: JSON.stringify({ model: "provider/model-name", stream: true, messages }),
signal: req.signal, // the browser hung up: stop asking the provider too
});
return new Response(upstream.body, { headers: { "content-type": "text/event-stream" } });
}Try it yourself
- 1
Stop before the first token
In the first example, change the visitor so it presses Stop immediately: replace the
MutationObserverblock withsetTimeout(() => document.getElementById("stop").click(), 110);.At 110 ms the request is sent (at 100 ms) but no token has arrived yet (the first one takes 40 ms). The console says
kept: ""and the page shows no text at all. Topic 3.2 decides what the screen should say in that case. - 2
Ask sooner
In the third example, change
++seen === 2to++seen === 1and++seen2 === 2to++seen2 === 1so the second question comes after the first piece instead of the second.The mixture changes (the first answer contributes less of itself), and the fixed version still prints the clean answer. The fix does not depend on timing; the bug does.
- 3
Abort twice
At the end of the second example, add
const c = new AbortController(); c.abort(); c.abort(); console.log(c.signal.aborted);.It prints
trueand does not throw. Callingabort()again is harmless, so a Stop button that is clicked twice needs no special code.
Code & diagrams
The example presses Ask, then Stop once "1 to 2" is on screen. The (server) line is what your server would see: the hang-up arrives as an abort on req.signal. The half answer stays on the page.
Preview
Console
Nothing logged yet.Expected console output
(server) the browser hung up, stop paying for tokens
stopped by the user, kept: "Orders ship in 1 to 2"Only a missing connection throws a TypeError. A 429 is a normal response with ok = false, so Stop handling and status checking are two separate jobs. With your own reason, the rejection IS your reason, so e.name is "Error" here, not "AbortError".
Preview
Console
Nothing logged yet.Expected console output
user pressed Stop: threw AbortError
abort with your own reason: threw Error: new question started
already aborted before sending: threw AbortError
nobody serves this path: threw TypeError: Failed to fetch
server says 429: resolved with status 429 ok = false
abort after the answer finished: no error, nothing to cancel
e.name === 'AbortError': true | e instanceof DOMException: trueBefore the fix the sentence is a mixture of two answers: the user would read nonsense. After the fix only the newest question can write.
Preview
Console
Nothing logged yet.Expected console output
without cancelling: " free withinOrders 7 ship days in of 1 delivery. to 2 working days."
with cancelling: "Orders ship in 1 to 2 working days."The component disappears right after the third piece, in the middle of the answer. Its effect cleanup aborts the request, so the server sees the hang-up and the read ends with AbortError. In React's development mode, effects also run twice on purpose: your cleanup is what makes that harmless.
Preview
Console
Nothing logged yet.Expected console output
user leaves the screen
(server) request cancelled
request ended: AbortErrorBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Treat Stop as an error
In the first example, change if (e.name === "AbortError") { to if (false) { and run it.
Break #2
Forget the cleanup
In the fourth example, replace return () => controller.abort(); with return undefined; and run it.
Myth vs fact
Myth
Aborting a request cancels the model's work.
Fact
It closes the browser's connection. The model stops only if your server also notices (req.signal) and forwards the cancellation to the provider. Tokens already produced are still billed.
Myth
An aborted fetch always rejects with an AbortError.
Fact
Only with the default reason. If you call abort(reason), the promise rejects with your reason. Compare signal.aborted when you pass reasons.
Myth
One AbortController can be reused for the next request.
Fact
Once aborted, its signal stays aborted. A reused controller makes every later request fail at once. Make a new controller per request.
Myth
Stopping removes the partial answer.
Fact
Abort stops the download. Text already received is still in your variable, and keeping it is your decision (Topic 3.2).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
AbortSignal.any([a, b])combines signals, so one request can end for either of two reasons (a Stop press or a timeout) while you still know which one fired throughsignal.reason(it holds the reason of the first source that aborted). MDN lists it as supported in all major browsers since March 2024; check its compatibility table against the browsers you must support. - ▸
The stale-write guard (
if (controller.signal.aborted) return) matters becauseabort()is not instant for everything already queued: a chunk that was decoded a moment earlier can still reach your loop. A request id (Topic 4.7) is the sturdier version of the same idea when several things can write. - ▸
Some proxies and hosting platforms buffer or ignore a client hang-up, so
req.signalmay fire late or never. Test your own deployment: start a long answer, close the tab, and watch the provider usage page. If tokens keep flowing, something in the chain is not forwarding the cancel. - ▸
React development mode mounts, unmounts and mounts every component once to expose missing cleanups. If your Stop logic is right, you will see one cancelled request and one that completes. That is the behaviour working, not a bug.
Remember this
- 1
The problem this solves: A model can take a minute to finish a long answer. Users change their mind, spot a typo in their question, or see the answer heading the wrong way. Without a Stop button they must wait, or reload the page. And when they ask a second question while the first is still streaming, the two answers can write into the same place and mix their words. This topic covers stopping cleanly and keeping old requests from touching new ones.
- 2
**The tool is
AbortController.** You create one withnew AbortController(). It has asignal(an object you hand tofetch) and anabort()method. When you callabort(), every operation that was given that signal stops.fetch(url, { signal })is the one you use here. One controller is for one request: once aborted, its signal stays aborted forever, so each new request needs a new controller. - 3
**What
abort()does, step by step.** The browser stops the download and closes the connection. Iffetchwas still waiting for the response, its promise rejects. If the response had already started streaming, the pendingreader.read()rejects instead. Either way you get an error whosenameis"AbortError"(aDOMException). Nothing is "undone": whatever text already arrived is still yours to keep (Topic 3.2). - 4
A Stop is not a failure. The
catchblock receives the abort as an exception, next to real errors such as a lost connection. If you show the same red error for both, a user who pressed Stop is told "something went wrong". Checke.name === "AbortError"first and move to a calmstoppedstate. If you pass your own reason,controller.abort(new Error("new question")), the rejection carries your reason instead, so comparecontroller.signal.abortedas well. (A timeout made withAbortSignal.timeout()rejects withTimeoutError, covered in Topic 1.4.) - 5
The server must hear about it. Closing the browser's side of the connection makes the request on your server fire its own abort signal (
req.signalin a route handler). If your route is in the middle of calling the model, pass that signal on (fetch(providerUrl, { signal: req.signal })) so the provider stops generating. Skip this and the browser looks stopped, while your server keeps pulling tokens from the provider, and you keep paying for them. Tokens produced before the abort are still billed; the saving is everything after it. - 6
Clean up when the screen goes away. A component can leave the page (the user navigates, a tab changes) while its request is still running. Abort in the effect's cleanup function, so the request ends with the component. And when the user sends a new question while one is running, abort the old request first. Pieces already in flight can still arrive for a moment after
abort(), so guard writes too:if (controller.signal.aborted) return;before touching state.
Explain it without notes
What exactly happens when controller.abort() is called during a streamed fetch?
How do you tell a deliberate Stop from a real network failure in catch?
Why must the server forward req.signal to the model call?
What can go wrong when a user asks a second question while the first answer is still streaming, and how do you prevent it?
Practice
Make the first example show "Stopped." in a paragraph #note when the answer was stopped, and nothing there when it finished normally.
Write a function makeAsker() that returns an ask(question) function. Each call must cancel the previous call, and ask must resolve with the answer text only for the newest call.
Trade-offs
- ↔
Cancelling everything that is replaced keeps the screen correct and saves money, but it also throws away work that was nearly finished. For short, cheap answers some products let the old request finish silently instead and only ignore its result.
- ↔
Forwarding the abort to the provider saves tokens but depends on your whole chain (proxy, host, framework) passing it along. It is worth testing once on the real deployment rather than assuming.
Done when you can
I can create one
AbortControllerper request and stop it from a button.I can tell an
AbortErrorfrom a real failure and show no error for a Stop.I can explain what the server sees when the browser aborts, and why
req.signalmust be forwarded.I can abort in an effect's cleanup so leaving the screen cancels the request.
I can stop an old request from writing into a new question.