I opened a keyboard-only chat panel and pressed Escape as soon as the answer started wandering off topic. The spinner disappeared, focus landed in the composer, and I started typing a shorter follow-up prompt. A late sentence then appeared under the cancelled turn, and the screen reader spoke it over my half-finished words. Was the remote model ignoring cancel, or was my own client applying bytes it had already promised to drop?
That failure is quieter than a crashed tab and more dishonest than a slow spinner, because the interface claims a finished state. I reproduced it in a single-file demo instead of leaning on a production log I cannot publish here. The same race appears whenever a browser stream outlives the button that was supposed to end it. If the label says cancelled while the reader is still appending, who exactly did the user stop?
What the panel claimed versus what the reader did
I wrote the visible states down before I touched the fetch loop, because a polished spinner can hide a contradictory transcript. A stop control that only toggles a boolean will look finished while the network task is still alive. I wanted the table in front of me so every later patch had to name a state, not a vibe. Can you point at one row and say which writer is allowed to mutate the turn?
| State | Composer | Stop control | Transcript | Status announcement |
|---|---|---|---|---|
| idle | enabled | removed from tab order | earlier turns only | silent |
| streaming | disabled | visible button | partial text, marked incomplete | one polite start |
| cancelling | disabled | busy, still present | partial text, still incomplete | Cancelling, once |
| cancelled | enabled and focused | removed | partial text, marked cancelled | Cancelled, once |
| stale chunk | must stay cancelled | removed | ignore the bytes | must stay silent |
The last row is the bug itself, not a feature you should document in a tooltip and ship. If rendering can still enter that row, the stop button is only a costume over an open reader. I kept the table beside the demo and refused to change copy until that row became unreachable. A state you cannot name will come back as a spinner tweak that leaves the reader running.
How I misread the symptom
The first note I wrote blamed the screen reader, because the late sentence was announced after I had already moved on. I assumed the live region was too chatty, so I removed the live region and listened to the panel again. The spoken glitch went quiet, and the visual glitch stayed, since cancelled text still grew on the screen. Why would muting that one announcement repair a network writer that had never actually been aborted?
I then blamed the remote model, because those late bytes clearly arrived after I had pressed Escape. That story felt satisfyingly neat, and it explained nothing about a loop that still accepted every chunk. A local mock that sleeps between two fragments reproduced the same growth with no model anywhere in the path. Have you ever shipped a server-side fix for a client loop that was still happily reading?
Patches that looked finished and still failed
I tried three patches that would have made a tidy before-and-after screenshot, and each one left the stale row reachable. First I hid the Stop button immediately, which only made the false finished state faster to believe. Next I cleared the partial text on cancel, which deleted the evidence and still let a late chunk repaint a ghost turn. Then I debounced rendering, which postponed the glitch until I had started typing, so the race became ruder instead of gone.
None of those patches asked whether the original reader was still alive and still allowed to write. Have you noticed how often a loading fix is really just a visibility fix in a nicer coat? I kept a short note beside each attempt so I would not congratulate myself for making the bug quieter. The generation check finally made the stale row unreachable, and the canceled network row was the first observation that agreed.
The checks that found the real break
I stopped swapping visual components around and pinned three observations before I changed any behavior again. The list is short on purpose, because more logs would have let me hide from the id mismatch. I wanted each logged line to answer a question the button label was no longer allowed to answer alone. Can a longer checklist replace a state table, or does it only help once those states already have names?
- Log the generation id on every incoming chunk, on cancel, and again on the next submit.
- Confirm that fetch actually received an AbortSignal, not merely that an AbortController exists somewhere in memory.
- Record focus and the status text at each transition, including any chunk that arrives after abort runs.
The chunk after cancel still carried generation 1, while the composer already belonged to generation 2. That single mismatch ended the argument that assistive technology was inventing a sentence on its own. The handler had flipped local state, and the first reader had never been told to stop writing. Would another CSS tweak, or a slower fade on the spinner, have surfaced that generation clash for me?
Commands I kept in the loop
I wanted a check I could rerun without clicking through the panel and trusting my memory of the race. The node test covers the writer logic, and the browser network row covers whether abort actually left the machine. If those two observations disagree, I trust the still-pending request over the button label every single time. Does your debug loop include a command, or only a story you retell after the demo looks calm?
node --test chat-stream.test.js
# In DevTools Network, press Escape and confirm the request shows (canceled).
# A row that stays pending after status is cancelled means abort never reached fetch.
Root cause, without the costume
The cancel handler set status to cancelled and cleared a loading flag that only the button could see. It never called abort, and the fetch options omitted the signal even after I had constructed a controller. The read loop also trusted the setter it closed over, so a late decode still appended to the finished turn. A local flag is not a generation guard, because the next network event can simply take that costume off.
Two in-flight requests were sharing one transcript slot, and both of them believed they still owned it. Which request is allowed to write into that slot once the user has already asked the panel to stop? I had answered that ownership question with a boolean, and the boolean lost the race to the open reader. Until those ids diverged in the log, I was debugging a costume instead of the writer underneath it.
A guard you can run before you pick a host
The module below is a local reproduction, not a production client and not a conformance claim of any kind. It uses a status union, a monotonic generation, and a controller whose signal actually reaches the reader. Swap the mock for your real endpoint only after the assertion proves those late chunks are dropped. I am not claiming that this small mock matches every server event framing or every error payload shape.
/** @typedef {'idle'|'streaming'|'cancelling'|'cancelled'|'error'} Status */
export function createChatStream() {
let generation = 0;
let controller = null;
let status = /** @type {Status} */ ('idle');
async function start(prompt, onEvent) {
controller?.abort();
const mine = ++generation;
controller = new AbortController();
status = 'streaming';
onEvent({ type: 'status', status, generation: mine });
try {
for await (const chunk of streamTokens(prompt, controller.signal)) {
if (mine !== generation) return;
onEvent({ type: 'chunk', text: chunk, generation: mine });
}
if (mine === generation) {
status = 'idle';
onEvent({ type: 'status', status, generation: mine });
}
} catch (error) {
if (error.name === 'AbortError' && mine === generation) {
status = 'cancelled';
onEvent({ type: 'status', status, generation: mine });
return;
}
if (mine === generation) {
status = 'error';
onEvent({
type: 'status',
status,
generation: mine,
message: 'Stream failed before completion'
});
}
}
}
function cancel(onEvent) {
if (status !== 'streaming') return;
status = 'cancelling';
onEvent({ type: 'status', status, generation });
controller?.abort();
}
return { start, cancel };
}
async function* streamTokens(prompt, signal) {
const parts = [`Draft for ${prompt}.`, ' Trailing clause that should not land.'];
for (const part of parts) {
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 50);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
yield part;
}
}
Notice the early return when mine no longer matches the generation that now owns the composer. Abort alone is not enough if a chunk was already decoded in the moment before the signal fired. Do you check the generation before you render text, or only in the moment before you call abort? I keep the assertion next to the demo so a quiet visual success cannot hide a trailing clause.
import assert from 'node:assert/strict';
import { createChatStream } from './chat-stream.js';
const events = [];
const chat = createChatStream();
const pending = chat.start('explain cancel', (event) => events.push(event));
chat.cancel((event) => events.push(event));
await pending;
const late = events.filter((event) => event.type === 'chunk' && event.text.includes('Trailing'));
assert.equal(late.length, 0);
assert.equal(events.at(-1).status, 'cancelled');
This assertion does not prove what a screen reader will speak after the status event finally lands. It only proves that the writer stopped applying the trailing clause after cancel had won the race. I still have to listen with the status element connected, because the test never opens a browser. If the trailing clause appears in the log, the guard is wrong, no matter how calm the button looks.
Wiring keys without trapping the panel
Escape should call the same cancel path as the Stop button, and it should not become a keyboard trap. I listen on the panel, ignore Escape when status is already idle, and move focus only after cancelled. The incomplete turn keeps a visible note that says the response was cancelled and the partial text was kept. Retry starts a new generation on purpose, and it does not rewrite the cancelled turn in place afterward.
panel.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
chat.cancel((update) => render(update));
});
What should be announced, and what should stay quiet
I announce the start of a stream once, and I announce cancel once, in a polite status element outside the transcript. Token chunks update the transcript visually, and they do not each poke a live region as if they were new messages. After cancel, focus moves to the composer only when the status event says cancelled, not while it still says cancelling. A chatty region would make an honest cancel feel like a brand new answer that arrived too late.
- The Stop control is a real button, and I remove it from the tab order when the stream is idle.
- I avoid aria-disabled on that button, because a disabled control can swallow the click we still need during cancelling.
- The cancelled turn exposes the partial text plus a plain sentence that names the outcome for anyone who cannot see the styling.
- A retry control is a separate button, and it does not pretend the old turn suddenly completed.
Markup the status, not the transcript
The status element is a paragraph with role status, and the composer points at it through aria-describedby. I do not put an aria-live attribute on the transcript, because every token would then become its own announcement. The Stop button is a native button beside the composer, so it stays in tab order only while a stream exists. Can you reach Stop, then Escape, then the composer without a mouse, even while the first chunk is still arriving?
<p id='stream-status' role='status'>Cancelled. Partial text kept.</p>
<button type='button' id='stop'>Stop</button>
<textarea id='composer' aria-describedby='stream-status'></textarea>
Focus order after cancel
Focus should land on the composer, not on a toast that disappears before a screen reader user can read it. I move focus in the status handler for cancelled, and I do not move it again on every arriving chunk. If a retry button appears, it comes after the composer in tab order so Escape and then typing still works. Would you rather recover the draft you started, or hunt for a toast that has already unmounted itself?
Using a free server only as a slower clock
I did not need a private GPU box just to hear this race on a real network path. Free model access and a free server option from MonkeyCode were enough to leave the mock and hit actual delay. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the same guarded client at that free server and used it only as a second target.
I am not quoting a model list, a token quota, or an uptime promise in this draft. Those figures were not in the sources I had, and I will not invent them to sound specific. The free server did not fix the bug, and it should not get credit for the generation check. What it changed was the gap between abort and the last buffered chunk, which made the stale write obvious in DevTools.
A slower network clock is useful while debugging, and it is not evidence that the host cancelled work for you. If your proxy buffers the stream, will the panel still tell the truth when that buffer finally arrives late? I still needed the generation guard, because the extra delay only made a client lie easier to see. Check the current access page before you depend on any host, since availability language goes stale fast.
The matrix I want attached to any bug report
Run the mock first, then one remote target, and write down the assistive technology instead of a local shrug. Hidden-tab cancellation is a different bug, so I am not folding visibility handling into this particular guard. I want the matrix attached to the report so we can see which transition failed, not which theme was on screen. Have you ever closed a streaming bug with a screenshot that never showed the cancelled state at all?
| Check | Input | Screen reader | Expected |
|---|---|---|---|
| Escape on first chunk | Escape | on | status cancelled, trailing clause absent |
| Stop, then type | Tab to composer | on | new keys not interrupted by old tokens |
| Cancel, then retry | Enter on Retry | on | new generation, old turn unchanged |
| Abort while the request fails | Escape | off | one error, composer recoverable |
| Signal omitted on purpose | Escape | off | request stays pending; this is the old bug |
Please include the browser, the operating system, and the assistive-technology version when a row fails. The useful detail is the transition, not a screenshot of a finished answer sitting under a calm spinner. I would rather read one failing row than another note about how the loading color feels. Which row would you actually trust if the button and the network panel disagreed?
Who should not ship this, and what it cannot prove
This guard stops the client from painting stale tokens, and that is the whole claim I will stand behind today. It does not prove the server halted generation, released capacity, or stopped metering the usage on its side. If your real risk is privacy or cost on the server, you need a cancel API and a test that watches the upstream log. A green client test can sit beside a server that is still generating into a buffer you no longer read.
Do not treat this pattern as your only accessibility plan, and do not treat ARIA as the repair for a still-running reader. Skip it when you cannot pass an AbortSignal into fetch, or when each chunk replaces the transcript and throws focus away. I would also skip it for untrusted partial HTML, because a generation check will not make streamed markup safe to inject. Who is this for, then, if it refuses to promise a server stop or a conformance badge at the end?
I have not measured latency, quota consumption, or conformance, and one listening pass is not a WCAG claim. The stream helper is a stand-in with a fixed delay, so your parser still has to honor the signal and the generation together. If you replay the race on a free inference host you can already reach, send the failing matrix row with versions. Please include your browser, operating system, and screen reader versions instead of another happy-path capture.
Top comments (0)