Stopping It, and Trying Again Safely
Cancellation is two-sided and retry is not free: a run that already called a tool has already changed something.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does stop actually stop, and what is safe to do again?
Someone can see the answer is going the wrong way. They want to stop it now, change the question, and try again — without wondering what the first attempt already did.
Call abort() on the fetch and clear the message. The request is cancelled, so nothing happened.
Aborting the client request stops you receiving bytes. It does not stop the server, which keeps running the model and paying for tokens until told otherwise.
- Aborting the client request stops you receiving bytes. It does not stop the server, which keeps running the model and paying for tokens until told otherwise.
- A tool the run already called has already had its effect. Aborting the stream does not un-send an email.
- Clearing the partial text throws away output the user watched being produced and may have wanted.
- Retrying the prompt re-runs the whole run, including the tool that already succeeded, so the effect happens twice (Retries, and the Duplicate Order).
- The abort surfaces as an error and gets reported to the user as a failure, when it was their own deliberate action.
What is actually happening
In the browser, not in the framework.
AbortControllercancels the client side of a fetch and rejects the in-flight promise with anAbortError— which is a control-flow signal, not a fault, and must not be rendered as one (Cancelling a Request Nobody Is Waiting For).- The server only learns of the cancellation if the transport propagates it, or if you tell it explicitly. For a long-running run the reliable pattern is an explicit cancel call carrying the run id.
- A run is a sequence of effects. Cancellation partitions it into what already happened and what will not — and only the backend knows where that line fell (Showing What the System Is Doing).
- Retry safety is idempotency, exactly as it is everywhere else: an operation is safe to repeat if repeating it produces the same end state (Retries, and the Duplicate Order).
What this makes the browser do
And which of it is avoidable.
- Tearing down the stream reader and releasing the buffered text.
- Releasing the transcript entry if it is discarded — or retaining it if kept, which is usually the better product decision.
- Cleaning up per-run listeners and timers; a cancelled run that leaves its handlers attached is an ordinary leak (Memory Leaks).
Two sides, and the line between them
The mental model that makes this tractable is that a run has a line through it at the moment of cancellation. Everything before the line happened. Everything after will not. The client does not know where the line fell — only the backend does — which is why the UI's honest position after a stop is "here is what was produced, and here is what completed", not "nothing happened".
1const controller = new AbortController()2 3async function stop(runId: string) {4 controller.abort() // stop reading now5 setStatus('cancelling')6 try {7 // tell the server, so it stops working and stops billing8 await fetch(`/api/runs/${runId}/cancel`, { method: 'POST' })9 } catch {10 // best effort — the local abort already happened11 } finally {12 setStatus('ended') // partial text kept, labelled13 announce('Response stopped')14 }15}16 17try {18 await readStream(controller.signal)19} catch (e) {20 // a deliberate stop is not a failure and must not be reported as one21 if ((e as Error).name !== 'AbortError') reportError(e)22}Two things people leave out: the explicit server-side cancel, and the AbortError guard that keeps user-initiated stops out of error tracking.
What is safe to run again
Retry is where cancellation stops being a UI question. The run may have sent an email, created a record, or charged something. Re-running the same prompt re-enters the same loop, and unless the effectful call carries an idempotency key the backend has no way to recognise the second attempt as the same intent.
This is not a new problem and it does not need a new solution — it is the same idempotency question the rest of this atlas already answers. What is new is only that the caller deciding to repeat the operation is a model rather than your code, which makes the key harder to place and more important to have.
What did the cancelled run already do?
when The run searched, retrieved and generated, with no writes.
cost None. Retry freely; this is the common case and should be the default path.
when Something was sent, created or changed before the stop.
cost Retry must carry the same idempotency key, or the user must be told what will repeat and asked (Retries, and the Duplicate Order).
when The stop landed mid-call and the outcome is genuinely unknown.
cost You cannot resolve this in the client. Show it as unknown, and give the user a way to check the actual state rather than guessing.
when The user is changing the question, not repeating it.
cost A new run with a new id. Do not reuse the previous run's idempotency keys — this is a different intent.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Stop pressed | Server keeps generating for another minute | Only the client aborted; nothing told the backend | Explicit cancel endpoint carrying the run id. |
| Stop pressed | Error toast saying the request failed | AbortError treated as a fault | Guard on AbortError and render a stopped state instead (Loading, Error, Empty — The States You Did Not Render). |
| Cancel acknowledgement lost | UI stuck in "Stopping…" | Waiting indefinitely on a response that will not arrive | Local timeout into a terminal state; the local abort already succeeded. |
| Retry after a completed send | The user receives two emails | No idempotency key on the effectful tool call | Key effectful calls per logical operation; the backend deduplicates. |
| Stop then immediate resubmit | Two answers interleaved in one message | Both runs writing to the same transcript entry | Key transcript entries by run id and ignore events from superseded runs. |
How to build it
Most important first.
- Make stop always available and always reachable, including by keyboard, for the entire duration of the run (Keyboard Operability).
- Cancel both sides: abort locally for immediate UI response, and tell the server so it stops working.
- Keep the partial output by default and label it as stopped. Discarding what the user watched appear is a surprising loss.
- Never render
AbortErroras a failure. A deliberate stop is a successful outcome of a different kind. - Make retry explicit about effects: if the run already performed an action, say so and let the user choose whether to repeat it.
- Give each run an id and each effectful tool call an idempotency key, so a repeat is recognisable as a repeat rather than a new request (Retries, and the Duplicate Order).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The stop control needs a persistent, unambiguous accessible name — "Stop generating" — and must never be the last thing in the tab order or hidden behind a hover state. It is the control a user most urgently needs when something is going wrong.
- Cancellation must be announced: "Response stopped". Silence leaves a screen-reader user unsure whether the stop registered, and pressing it repeatedly is the natural response (Live Regions and Announcement).
- When the run ends by cancellation, do not move focus. The user pressed a button; leave them where they are and announce the outcome (Focus Management).
- If a retry replaces content in place, announce that new content has arrived rather than relying on it visibly changing.
- A confirmation prompt for repeating an effectful action is a dialog with all the obligations of one — focus trapped, Escape closes, focus restored (Accessible Component Patterns).
What can go wrong
- A stop that only stops the UI, leaving the server generating for another minute at full cost.
- A stop that leaves the interface in
cancellingforever because the acknowledgement never arrived and nothing timed it out. - Retry duplicating a side effect the first attempt completed.
- Rapid stop-and-resubmit producing two live runs that interleave into the same transcript entry.
- An abort reported to error tracking as an exception, burying real errors in noise from users pressing stop (Frontend Error Tracking).
- A cancel can arrive while a tool call is in flight, so the tool completes after the run is marked cancelled — the effect exists and the UI says it did not.
- A retry can race the first attempt's still-pending effect, producing the action twice without an idempotency key.
- Stop followed immediately by resubmit can produce two live runs writing into the same transcript entry unless runs are keyed (Out-of-Order Responses).
- Cancellation must be authorized: a run id is not a capability, and one user must not be able to cancel another's run (What the Frontend Is Responsible For in Auth).
- A retry is a fresh authorization decision, not a replay of a previous one. The server checks again (A Suggestion Is Not an Authorization).
- Idempotency keys must be generated per logical operation and not reused across different operations, or a retry can be matched to the wrong request (Retries, and the Duplicate Order).
- "Abort cancels the operation." It cancels your side of it. The server keeps going unless told.
- "Nothing happened, so retry is free." A run that called a tool already changed something. Retry safety is a property of the operation, not of the stream.
- "AbortError means something went wrong." It means the user pressed stop.
- "Cancel and retry are UI concerns." They are correctness concerns that happen to have buttons.
Measuring it, and what changes in the field
- Cancellation rate, and where in a run it happens — a cluster after a particular tool is a signal about that tool, not about users.
- Retry rate after cancellation, which distinguishes "wrong answer" from "too slow".
- Server-side work performed after a client cancel, which is the direct cost of not propagating the stop.
- Abort errors leaking into error tracking, which should be zero (Frontend Error Tracking).
- On a flaky network, cancellation acknowledgements are themselves unreliable, so the UI needs a local timeout rather than waiting indefinitely.
- When a run is expensive, propagating cancellation is a direct and measurable cost saving as well as a UX improvement.
- With several runs in flight across tabs, cancelling one must not affect another — run identity has to be real (Auth Across Tabs).
- Explicit server-side cancellation costs an extra endpoint and an extra round trip, and saves real compute on every stop.
- Keeping partial output preserves the user's work and clutters the transcript with incomplete answers — labelling solves most of it.
- Confirming before repeating an effectful action adds friction exactly where friction is warranted.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThat aborting a client request does not undo a server-side effect follows from where the effect happened, so it holds for every transport, vendor and framework.
- SPEC-EVOLVINGWhether a provider propagates a dropped connection as a cancellation, and whether it stops billing when it does, varies between vendors and changes. Do not rely on transport-level abort reaching the model; send an explicit cancel and treat the connection drop as a hint.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a cancel racing an in-flight effect is the classic uncertain-outcome problem, and the honest interface answer is to show it as uncertain rather than to guess.