Last week a teammate sent me a two-line message at the end of the day: "Stop works the first time. The second time, the chat just keeps typing and I can't make it stop." She had been testing the retry path after a 500 from our own gateway, and by the time I opened the tab, the transcript was still growing with nobody at the keyboard. Nothing in the console looked like an exception, which is usually the sign that the bug lives in the state machine rather than the network.
Here is the symptom table I wrote before touching any code, because the shape of the failure matters more than the eventual stack trace. Notice that every row is a second interaction: first interactions were all clean, and that is exactly what let the bug survive three review passes.
| User action | Expected | Actual |
|---|---|---|
| Send, then Stop | stream ends, status becomes Stopped, composer re-enabled |
correct, first attempt only |
| Send again, then Stop | stream ends again | tokens keep arriving, Stop looks inert |
| Retry after HTTP 500 | one new attempt, one status change | UI stuck on Generating…, Retry gone |
| Screen reader user, same path | "Generating" then "Stopped" | one announcement, then silence |
Three symptoms, one object: the AbortController.
Reproduce it before you theorise
I do not trust a fix I cannot trigger on demand, so step one is always a deterministic reproduction. Mine takes about ninety seconds and needs no network at all.
- Stub
fetchso it emits five chunks, one every 200 ms, then closes. - Send a prompt, click Stop after chunk two, confirm the status reads
Stopped. - Send a second prompt, click Stop after chunk two, and watch the transcript.
- Log
signal.abortedat the top of the request function, every time.
// repro-harness.ts — run with `npx tsx repro-harness.ts`
let calls = 0;
globalThis.fetch = async (_url, init) => {
calls += 1;
console.log(`attempt ${calls}, signal.aborted at start:`, init?.signal?.aborted);
const body = new ReadableStream({
async start(controller) {
for (const chunk of ["one ", "two ", "three ", "four ", "five"]) {
if (init?.signal?.aborted) {
return controller.error(new DOMException("Aborted", "AbortError"));
}
controller.enqueue(new TextEncoder().encode(chunk));
await new Promise((r) => setTimeout(r, 200));
}
controller.close();
},
});
return new Response(body, { status: 200 });
};
The second row of that log is the entire investigation: attempt 2, signal.aborted at start: true. The stream never had a chance, because it was born cancelled.
The root cause was a useRef with a lifetime problem
The component created one controller when it mounted and reused it for every request, which feels tidy and is wrong. An AbortController is a one-shot object: once you abort it, the signal stays aborted forever, and every future fetch that receives it rejects almost immediately.
// buggy
const controllerRef = useRef(new AbortController());
async function send(prompt: string) {
setStatus({ kind: "streaming" });
try {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: controllerRef.current.signal, // aborted forever after the first Stop
});
await readStream(res);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; // silent
setStatus({ kind: "failed", message: "Something broke." });
}
}
Two bugs compounded into one confusing experience. The cancelled attempt returned early and left status at streaming, so the UI kept advertising Generating… and hid the Retry button. The reused signal then made the next attempt abort within milliseconds, which the same early return swallowed again: a loop of silence that looked exactly like a hung network.
Can you see why the first attempt was clean? Fresh controller, fresh signal, and the abort path ran precisely once.
Fix: bind the controller to the attempt, not the component
I stopped thinking in terms of "the request" and started thinking in terms of "an attempt", because that is the unit a user can cancel, retry, or duplicate. Each attempt carries its own controller, its own id, and its own terminal state.
type Status =
| { kind: "idle" }
| { kind: "streaming"; attempt: number }
| { kind: "cancelled"; attempt: number }
| { kind: "failed"; attempt: number; message: string; retryable: boolean };
let nextAttempt = 0;
let active: { id: number; controller: AbortController } | null = null;
async function run(prompt: string) {
active?.controller.abort(); // never leave two attempts alive
const attempt = { id: ++nextAttempt, controller: new AbortController() };
active = attempt;
setStatus({ kind: "streaming", attempt: attempt.id });
try {
if (attempt.controller.signal.aborted) throw new Error("attempt started aborted");
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: attempt.controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
for await (const chunk of readChunks(res)) {
if (attempt.id !== active?.id) return; // a superseded attempt writes nothing
append(chunk);
}
setStatus({ kind: "idle" });
} catch (err) {
if (attempt.controller.signal.aborted) {
setStatus({ kind: "cancelled", attempt: attempt.id }); // a real state, announced
return;
}
setStatus({
kind: "failed",
attempt: attempt.id,
message: "Connection dropped.",
retryable: true,
});
}
}
function stop() {
active?.controller.abort();
if (active) setStatus({ kind: "cancelled", attempt: active.id });
}
The important change is not the extra types; it is that cancellation became a state instead of a missing return. My previous code treated "the user stopped it" as an absence of information, which is why nothing was announced and nothing re-enabled.
What the assistive technology actually heard
Fixing the controller did not fix the announcement, and this is the part where I usually lose an afternoon. During the stuck state the log region carried aria-busy="true" and never cleared, so polite updates inside it were deferred in my Chrome plus NVDA pass. I now treat a busy flag that outlives its attempt as a bug in its own right, not as cosmetic debt.
- Keep exactly one
role="status"region witharia-live="polite", outside the scrolling transcript. - Announce transitions rather than tokens: "Generating", "Stopped", "Retry available".
- Do not move focus on failure; return focus only when the user explicitly asked for it.
- Keep Stop focusable while streaming, and make Retry the next tab stop after the error text.
- Reference the error text from Retry with
aria-describedby, so the button is never an unlabelled mystery.
My test plan, and the matrix I run it in
| Scenario | Input | Expected outcome |
|---|---|---|
| Cancel then send | Stop, then new prompt | second stream starts, Stop works again |
| Cancel twice quickly | double-click Stop | one announcement, no error flash |
| Retry after 500 | stub 500, click Retry | exactly one new request, Retry disabled while pending |
| Superseded stream | Send while streaming | old attempt writes nothing to the transcript |
| Keyboard only | Tab to Stop, press Enter | focus stays put, status updates |
Environments I actually run: Chrome with NVDA on Windows 11, Safari with VoiceOver on macOS, Firefox with Orca on Linux, and iOS Safari with VoiceOver. Firefox deserves more attention than it gets here, because older code paths sometimes surface cancellation as an Error rather than a DOMException.
Where MonkeyCode genuinely fit into this loop
Two things about this bug needed an environment I do not keep lying around: a deployed origin, because proxy buffering changes how chunks arrive, and a large number of failed attempts, because I ran the cancel-and-retry loop dozens of times per session. I used MonkeyCode's free model access to drive those runs without rationing tokens, and its free server option to host the same page on a second origin and compare stream timing against localhost. The operator currently advertises a ten-million-token free allowance plus a free server option; check the project page for the current terms before planning around them, because free-tier details move. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
It is a reasonable way to get a cheap second origin for this exact class of test. It is not a replacement for your own logging, and no hosted tier will tell you that your signal was born aborted.
Limitations and who should skip this
- If your interface has no cancel affordance, an attempt id is overhead; one controller per mount is genuinely fine.
- If your framework already scopes mutation cancellation per call, copy the guard pattern, not the plumbing.
- This pattern does not fix duplicated user messages; that needs an idempotency key on the request instead.
- Free allowances change over time, so never let a reproduction plan depend on a permanent quota.
What I check first next time
- Log
signal.abortedat request start and treattrueas a hard error. - Make cancellation a rendered state, never an early
return. - Assert one live attempt with a module-level id, and bail out of stale writers.
- Confirm the
aria-busyflag clears on every terminal transition. - Re-run the cancel-retry loop on a second origin before calling anything fixed.
A stop button that works exactly once is never a browser quirk; it is a lifetime bug wearing a network costume. If you hit the same silence, send me the browser, OS, and assistive-technology versions plus the exact interaction that broke, and I will add the row to the matrix.
Top comments (0)