In a lab walkthrough I assembled for this article, the failure arrived in the worst possible keyboard order. AI coding assistants in the browser all ship a Stop control now, but many of those controls only paint a disabled state. I tabbed to Stop Generating, pressed Enter, and watched the control go grey while tokens kept landing. Have you ever cancelled a generation from the keyboard and still heard the model talking afterward?
The visual state lied because the demo held two fetches and only one Stop control. Switching models opened a second request that stole the button without aborting the previous AbortController. The live region stayed polite, so leftover chunks from the first request still reached assistive technology. Why should a disabled button be the recovery path for a stream you already tried to cancel?
The interaction that actually failed
I used one keyboard-only path, and you should run it before trusting a mouse click on Stop. The point of this path is overlapping ownership during a model switch, not raw token speed. If the first request is still open when the second handle binds the button, cancel becomes theatre.
- Focus the composer, submit a prompt, and wait until tokens begin appending to the transcript.
- Open the model switcher from the keyboard, then choose a different model without waiting for completion.
- Tab to Stop Generating and press Enter once, keeping focus where it lands afterward.
- Listen for further tokens, and check whether that Stop control is disabled while still focused.
The expected UI keeps a single request as abort owner, with Stop enabled until abort completes. Focus then returns to the composer, and a polite status says that generation was cancelled. The broken UI disables Stop against a brand-new idle handle while the old stream continues writing. Can a screen-reader user tell which model is still speaking into the transcript?
Before: [Stop focused, disabled] request A still appending tokens
After: [composer focused] status: Generation cancelled
State table before the fix
I placed this table early because happy-path screenshots never show two owners at once. Named states matter more than a spinner, especially when a switcher can create a second fetch. Read the switching row first, then ask who the Stop button is actually bound to.
| Named state | Fetch | Abort owner | Stop button | Live region | Focus |
|---|---|---|---|---|---|
| idle | none | none | hidden | silent | composer |
| streaming | request A | controller A | enabled | tokens from A | Stop or transcript |
| switching | A open, B starting | controller B only | bound to B | tokens from A and B | model switcher |
| cancelled-looking | A still open | none | disabled | leftover tokens from A | trapped on Stop |
| cancelled | aborted and closed | cleared | hidden | Generation cancelled | composer |
The switching row is the entire bug, packed into one quiet frame that the happy path never renders. Two streams share one button there, and that button reports the wrong request identity to the rest of the UI. If you cannot name the abort owner in that frame, extra ARIA on the spinner will not save the interaction.
Symptom to evidence
I did not start with role attributes, because the document was narrating a network story that never happened. Reusable debugging here means correlating identities across fetch, DOM, and focus, not sprinkling aria-busy on every waiting glyph. The five checks below are the ones that actually exposed the leaked controller.
- Log
requestId,signal.aborted, andmodelIdon every chunk, not only when the fetch begins. - Stamp each transcript node with
data-request-idso leftover writes remain visible inside DevTools. - Record which element currently owns
aria-busy, and which transition is allowed to clear it. - Watch focus after Enter on Stop, because a disabled button is a trap rather than a status message.
- Compare a fast local mock against a slower remote stream, since cancel races almost never appear in
setTimeoutdemos.
The local mock finished within a handful of milliseconds, so the model switch never overlapped a live response body. That is why the unit tests looked green while the keyboard path still leaked tokens into the transcript. I needed a slower endpoint that still respected cancellation, not another skeleton overlay sitting on top of the composer.
Where a free remote stream helped the reproduction
I pointed the lab client at MonkeyCode because I needed a real streaming response I could cancel from the keyboard. Disclosure: This article was prepared as part of MonkeyCode's product outreach, which is the only product relationship I need to state here. MonkeyCode is an open-source project with free model access and a free server option for this kind of lab.
I am not naming models, quoting token allotments, or publishing timings, because I did not measure those claims for this article. The useful property was latency that overlapped with Tab, Enter, and a model change during an open stream. If your mock always resolves before the next keystroke, abort ownership bugs stay invisible in both tests and demos.
A teaching fetch wrapper for the lab looks like the snippet below, and it is not a production SDK. Treat the aborted check as part of the contract, not as optional cleanup after the button has already gone grey.
type StreamHandle = {
requestId: string;
modelId: string;
controller: AbortController;
};
async function startStream(
url: string,
body: unknown,
handle: StreamHandle,
onChunk: (requestId: string, text: string) => void,
) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: handle.controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`stream failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (handle.controller.signal.aborted) break;
onChunk(handle.requestId, decoder.decode(value, { stream: true }));
}
}
Notice the aborted guard before onChunk, which is easy to skip when the Stop button already looks idle. Without that check, a late chunk still mutates the transcript after the control claims the request has ended. Have you logged aborted at chunk arrival time, or only inside the click handler that tries to stop the stream?
Root cause: the button cancelled a controller that no longer existed
The model switcher allocated a new AbortController, wrote it into component state, and rebound Stop to the latest handle. The previous handle left state without abort(), so request A kept pumping tokens into a polite live region. For one render, the latest handle looked idle, which disabled Stop while keyboard focus was still parked on that button. React was honest about its local state; that state simply described the wrong network owner.
// Anti-pattern from the lab: switching models replaces the handle without aborting.
function switchModel(nextModelId: string) {
setModelId(nextModelId);
setHandle({
requestId: crypto.randomUUID(),
modelId: nextModelId,
controller: new AbortController(),
});
}
There is no abort call, no status announcement, and no focus restore anywhere in that switcher function. The live region still contained leftover text from request A, so the cancelled generation sounded like output from the newly selected model. Does your switcher abort the open owner first, or does it only call setState and hope the old fetch notices?
The typed state machine that owns cancel
I collapsed the interface onto one owner, which means StreamHandle is singular or it is absent. Every transition is a named event, including SWITCH_MODEL, so a mid-stream change cannot skip abort and still look successful. The reducer below is a teaching demo, not battle-tested library code from a production chat.
type ChatEvent =
| { type: "SUBMIT"; modelId: string; prompt: string }
| { type: "SWITCH_MODEL"; modelId: string }
| { type: "CHUNK"; requestId: string; text: string }
| { type: "CANCEL" }
| { type: "ERROR"; message: string }
| { type: "COMPLETE"; requestId: string };
type ChatState =
| { status: "idle"; announcement: string }
| {
status: "streaming";
handle: StreamHandle;
buffer: string;
announcement: string;
}
| { status: "cancelling"; handle: StreamHandle; announcement: string }
| { status: "error"; message: string; announcement: string };
function reduce(state: ChatState, event: ChatEvent): ChatState {
switch (event.type) {
case "SWITCH_MODEL": {
if (state.status === "streaming") {
state.handle.controller.abort();
return {
status: "cancelling",
handle: state.handle,
announcement: "Cancelling the previous model before switching.",
};
}
return { ...state, announcement: `Model set to ${event.modelId}.` };
}
case "CANCEL": {
if (state.status !== "streaming") return state;
state.handle.controller.abort();
return {
status: "cancelling",
handle: state.handle,
announcement: "Cancelling generation.",
};
}
case "CHUNK": {
if (state.status !== "streaming") return state;
if (event.requestId !== state.handle.requestId) return state;
if (state.handle.controller.signal.aborted) return state;
return { ...state, buffer: state.buffer + event.text };
}
default:
return state;
}
}
The requestId equality check and the abort-before-switch path are the actual fix for the leaked speech. Chunks from a dead owner become no-ops instead of extra spoken markdown in the polite live region. Should a cancelled request ever be allowed to speak through the same live region again after Stop has fired?
Semantic structure, focus, and announcements
Stop Generating remains a real button, not a clickable div with a late keydown handler bolted on. It is disabled only while status is cancelling, never because some other handle looked idle for a single frame. After cancel settles, focus moves back to the composer, and a polite atomic status node announces that generation was cancelled. Pointer users fire the same CANCEL event through that button, so there is no hover-only stop glyph hiding beside the stream.
<section aria-labelledby="chat-heading">
<h2 id="chat-heading">Streaming reply</h2>
<div id="transcript" aria-live="polite" aria-relevant="additions"></div>
<p id="status" aria-live="polite" aria-atomic="true"></p>
<label>
Prompt
<textarea id="composer"></textarea>
</label>
<button type="button" id="stop" data-action="cancel">Stop generating</button>
</section>
I keep token text in the transcript and discrete status in a second live region on purpose for this demo. Mixing cancel messages into the same node that receives tokens is how spoken status falls behind the stream. Focus rules I encode as lab checks, rather than as a vague accessibility appendix, are listed next.
- Submit moves focus to Stop Generating after the first token arrives, not while the request is still connecting.
- Cancel restores focus to the composer and never leaves the user parked on a disabled control.
- Model switch during streaming aborts, announces the cancel, and only then starts the next handle.
- Escape dispatches the same
CANCELevent as the button, so keyboard users are not hunting for Stop.
Error, cancel, and retry without a silent composer
When the remote endpoint returned an error, I refused to cover the composer with a toast that could not take focus. The error state exposes a focusable status region with Retry and Dismiss as real buttons in the tab order. Retry allocates a new requestId and a new AbortController; it never reuses a signal that already aborted, because aborted signals stay aborted. If cancel is still in flight, Retry is omitted, which sounds obvious until a leaked handle renders both actions at once.
function retryFromError(
state: ChatState,
modelId: string,
prompt: string,
): ChatEvent {
if (state.status !== "error") {
throw new Error("retry is only legal from error");
}
return { type: "SUBMIT", modelId, prompt };
}
Who owns the in-flight request after Retry is pressed from that error status region? If answering that question needs a paragraph, the state machine still has two owners and a lying button. Pointer-independent retry matters here, because the people who cancelled from the keyboard are the same people who need to recover.
Environment-specific QA matrix
Please reproduce with versions written down, not with a vague sense that VoiceOver sounded generally fine yesterday. The transition that failed in the lab is streaming, then switch model, then Stop, then leftover tokens still arriving. This matrix is a proposed checklist for that path, not a claim that I certified every cell on a given day.
| Environment | Assistive tech | Transition | Pass if |
|---|---|---|---|
| Chrome current, Windows | NVDA | streaming → switch → Stop | no further tokens spoken; composer focused |
| Firefox current, macOS | VoiceOver | streaming → Escape | Generation cancelled is spoken; composer focused |
| Safari current, iOS | VoiceOver | streaming → Stop | Stop does not remain a disabled tab stop |
| Keyboard only, any browser | none | Tab to Stop, then Enter | the pointer is unnecessary for cancel |
If the first token arrives after you have already switched models, you are still inside the original race. Slow the stream on purpose with a remote endpoint or a throttled local ReadableStream before you declare cancel done. Otherwise the QA grid will pass for the same reason the unit tests passed: nothing lived long enough to overlap a keystroke.
Limitations, and who should not copy this
This pattern is for user-cancellable token streams inside a browser chat surface, not for arbitrary background work. It will not cancel server work that ignores AbortSignal, and it is not a job queue for long-running agents that commit side effects. I am not claiming conformance for your product, because I cannot hear your live region or see your focus order in production.
Do not copy this approach if you only mock streams with setTimeout at zero delay, or if Stop is a decorative icon. Skip it when model switch is allowed to multiplex two audible replies, because the machine assumes a single abort owner at a time. Also skip a free remote endpoint if you need named models, guaranteed capacity, or a production service level for real users.
The free model access in this walkthrough was enough to keep a stream open during keyboard interaction and model switching. It is not a promise about rate limits, hardware, duration, or benchmarked quality of any particular model. If that remote stream is unavailable, use a local throttled ReadableStream and keep the same reducer, because the bug lives in ownership.
What I would inspect next
If tokens still speak after cancel, dump requestId from the DOM node that mutated after Stop. If focus disappears into nowhere, you probably disabled the control that currently held the tab stop. If the cancel announcement arrives late, status text is likely sharing a live region with the transcript buffer. None of those failures are library mysteries; they are abort-ownership bugs you can see with a stamped chunk log.
If you need a slower public stream for the same keyboard reproduction, MonkeyCode's free server option is a convenient target for the lab client. Run the streaming-to-switch-to-Stop transition on your own machine, then write down the browser, operating system, and assistive-technology versions that still leak chunks. I care more about that exact failed transition than about a screenshot of a happy stream finishing in the default theme.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Top comments (0)