Command Palette

Search for a command to run...

Hectal
PHASE 3Intermediate ~33 min· topic 1 of 7

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 to fetch, which listens to it.
AbortError
The error a cancelled fetch or stream read throws. Its name is "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().

stop.jswhole filejs
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.

What the Stop press sets in motiondiagram
Rendering diagram…

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.

app/api/chat/route.ts (real app)whole filets
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. 1

    Stop before the first token

    In the first example, change the visitor so it presses Stop immediately: replace the MutationObserver block with setTimeout(() => 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. 2

    Ask sooner

    In the third example, change ++seen === 2 to ++seen === 1 and ++seen2 === 2 to ++seen2 === 1 so 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. 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 true and does not throw. Calling abort() again is harmless, so a Stop button that is clicked twice needs no special code.

Code & diagrams

A Stop button that keeps what has arrived

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

Press Run to see the page.

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"
Seven things that end a fetch, and how each one looks in catch

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

Press Run to see the page.

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: true
A new question must cancel the old one

Before 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

Press Run to see the page.

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."
Leaving the screen stops the request

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

user leaves the screen
(server) request cancelled
request ended: AbortError

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

terminal
$ The console, and the status line on the page
── what you'll see ──
(server) the browser hung up, stop paying for tokens
failed: signal is aborted without reason

Break #2

Forget the cleanup

In the fourth example, replace return () => controller.abort(); with return undefined; and run it.

terminal
$ The console
── what you'll see ──
user leaves the screen
finished

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 through signal.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 because abort() 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.signal may 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. 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. 2

    **The tool is AbortController.** You create one with new AbortController(). It has a signal (an object you hand to fetch) and an abort() method. When you call abort(), 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. 3

    **What abort() does, step by step.** The browser stops the download and closes the connection. If fetch was still waiting for the response, its promise rejects. If the response had already started streaming, the pending reader.read() rejects instead. Either way you get an error whose name is "AbortError" (a DOMException). Nothing is "undone": whatever text already arrived is still yours to keep (Topic 3.2).

  4. 4

    A Stop is not a failure. The catch block 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". Check e.name === "AbortError" first and move to a calm stopped state. If you pass your own reason, controller.abort(new Error("new question")), the rejection carries your reason instead, so compare controller.signal.aborted as well. (A timeout made with AbortSignal.timeout() rejects with TimeoutError, covered in Topic 1.4.)

  5. 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.signal in 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. 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

01

What exactly happens when controller.abort() is called during a streamed fetch?

02

How do you tell a deliberate Stop from a real network failure in catch?

03

Why must the server forward req.signal to the model call?

04

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

01

Make the first example show "Stopped." in a paragraph #note when the answer was stopped, and nothing there when it finished normally.

02

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 AbortController per request and stop it from a button.

  • I can tell an AbortError from a real failure and show no error for a Stop.

  • I can explain what the server sees when the browser aborts, and why req.signal must 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.