A support message landed in my inbox with a detail I almost skipped: the reply was there on screen, but the screen reader said nothing at all. It only happened when the first attempt failed and the person pressed Retry. Sighted testers, including me, had shipped that build a day earlier and called it done.
That pattern — visible text, silent assistive tech, one specific path — is usually a live region problem. But the live region was present, correctly labelled, and working on the happy path. So what else can make a region go quiet while its text keeps growing?
This is the story of a single stale attribute, two code paths that both claimed ownership of it, and the state table I should have written before the first line of streaming code.
The report was suspiciously specific
The reporter described three details, and every one of them mattered:
- First answer streams in, then fails mid-sentence with an inline error.
- Pressing Retry produces a complete answer that looks normal.
- VoiceOver stays silent for the retry, but announces the error message fine.
Notice what the third point rules out. The live region is not broken, because it spoke the error. Announcements are not globally disabled, because the person heard something. The failure is scoped to the retried message node, which means the bug lives in the lifecycle of that node, not in the live region configuration.
First hypothesis: "we forgot the live region" (wrong)
My first guess cost me twenty minutes. I assumed the retry re-rendered the message into a fresh element and lost aria-live on remount. I added a MutationObserver to the list and logged every attribute change during a failed-then-retried stream.
The log told a different story. Attribute changes on the message element during attempt one: aria-busy="true". During the error: nothing. During the retry: nothing. During completion: nothing.
[attempt 1] aria-busy -> true
[error] (no attribute changes)
[retry] (no attribute changes)
[done] (no attribute changes)
So aria-busy went true and never came back. A busy live region is a region whose updates assistive technology may defer until the busy state clears — which is precisely the behaviour we were seeing. The region was not silent because it was misconfigured; it was silent because we had told it, permanently, "I am still working."
Reproducing it in one file
I could not debug this against a provider that only fails on Tuesdays. I needed a stream that reliably dies after N tokens, so I wrote a forty-line endpoint with a failAfter query parameter and no dependencies.
// server.mjs — node server.mjs
import { createServer } from 'node:http';
const TOKENS = ['Accessible ', 'streams ', 'announce ', 'one ', 'settled ', 'state.'];
createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/stream') return void res.writeHead(404).end('not found');
const failAfter = Number(url.searchParams.get('failAfter') ?? -1);
const delay = Number(url.searchParams.get('delay') ?? 120);
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
let i = 0;
const timer = setInterval(() => {
if (failAfter >= 0 && i === failAfter) {
clearInterval(timer);
res.write('event: error\ndata: {"message":"upstream stopped"}\n\n');
return void res.end();
}
if (i >= TOKENS.length) {
clearInterval(timer);
res.write('event: done\ndata: {}\n\n');
return void res.end();
}
res.write(`data: ${JSON.stringify({ t: TOKENS[i++] })}\n\n`);
}, delay);
req.on('close', () => clearInterval(timer));
}).listen(process.env.PORT || 8080);
You need a stream that dies on demand and a URL you can open from a phone, a VM, and a colleague's laptop. That is where MonkeyCode earned its place in this workflow: the free model access let me swap upstreams to check whether the stream shape was the provider's fault, and the free server option was enough to host this endpoint behind a real URL instead of localhost. The token allowance I have seen quoted is 10 million, which I am taking from the operator rather than measuring myself.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The buggy client, in eleven lines
The original code is short, readable, and wrong in a way that reviews never catch:
// ❌ the bug: imperative busy, cleared only on the happy path
async function send(prompt, row) {
row.setAttribute('aria-busy', 'true');
const res = await fetch(`/stream?failAfter=3`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let text = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
text += value;
row.textContent = text;
}
row.setAttribute('aria-busy', 'false');
}
// ❌ and retry(): pasted from send(), missing the attribute line entirely
async function retry(row) {
const res = await fetch(`/stream`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let text = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
text += value;
row.textContent = text;
}
}
Attempt one sets the attribute, then throws before the clear line. retry() streams into the same row and never touches the attribute, because the line was lost in the copy-paste. Two functions own one attribute, and the second one only knows half the contract.
Why the happy path hid it
On a successful first attempt, aria-busy flips true and then false, so announcements work. On a failed first attempt it stays true, and the retry inherits a region that is permanently busy. Is that a bug you would find by clicking around? Almost certainly not — the pixels are identical.
The state table I should have written first
Before touching code, I wrote the table. Every column is something a user can observe, and every row must be true at the same time:
| State | aria-busy |
Live region | Focus |
|---|---|---|---|
idle |
false | empty | composer |
streaming |
true | silent (tokens are not announced) | Stop stays reachable |
complete |
false | final text, once | composer |
error |
false | "Reply stopped. Retry available." | Retry is next in tab order, not focused |
cancelled |
false | "You stopped this reply." | composer |
Two rules fall out of that table. First, aria-busy is true in exactly one state. Second, it must be false in every terminal state, including the ones reached by failure.
Fix: derive aria-busy, never set it
When a value must be true in exactly one state, stop storing it as its own value. I replaced the imperative attribute writes with a typed reducer and derived aria-busy from the state on every render.
type ChatState =
| { kind: 'idle' }
| { kind: 'streaming'; id: string; text: string; attempt: number }
| { kind: 'complete'; id: string; text: string; attempt: number }
| { kind: 'error'; id: string; text: string; error: string; attempt: number }
| { kind: 'cancelled'; id: string; text: string; attempt: number };
type Event =
| { type: 'send'; id: string }
| { type: 'token'; text: string }
| { type: 'done' }
| { type: 'fail'; error: string }
| { type: 'cancel' }
| { type: 'retry' };
export const isBusy = (s: ChatState) => s.kind === 'streaming';
export function reduce(state: ChatState, event: Event): ChatState {
switch (event.type) {
case 'send':
return state.kind === 'streaming'
? state
: { kind: 'streaming', id: event.id, text: '', attempt: 1 };
case 'token':
return state.kind === 'streaming' ? { ...state, text: state.text + event.text } : state;
case 'done':
return state.kind === 'streaming'
? { kind: 'complete', id: state.id, text: state.text, attempt: state.attempt }
: state;
case 'fail':
return state.kind === 'streaming'
? { kind: 'error', id: state.id, text: state.text, error: event.error, attempt: state.attempt }
: state;
case 'cancel':
return state.kind === 'streaming'
? { kind: 'cancelled', id: state.id, text: state.text, attempt: state.attempt }
: state;
case 'retry':
return state.kind === 'error'?
{ kind: 'streaming', id: state.id, text: state.text, attempt: state.attempt + 1 }
: state;
}
}
Rendering then becomes a pure function of state, and there is no code path that can leave the attribute behind:
<li
className="msg msg--assistant"
aria-busy={isBusy(state) ? 'true' : 'false'}
>
{state.kind !== 'idle' ? state.text : null}
</li>
Note that focus is untouched in every branch. On error, the live region speaks the message and the Retry button sits next in reading order; it does not grab focus and it does not move the caret. Users decide when to retry, not us.
Tests that would have caught it
The reducer is small, so the test is table-driven. It asserts the invariant, not a specific string, which means new states cannot quietly break it.
import test from 'node:test';
import assert from 'node:assert/strict';
import { reduce, isBusy, type ChatState, type Event } from './chat-machine.ts';
const run = (events: Event[]) => events.reduce<ChatState>(reduce, { kind: 'idle' });
const paths: Event[][] = [
[{ type: 'send', id: 'm1' }],
[{ type: 'send', id: 'm1' }, { type: 'done' }],
[{ type: 'send', id: 'm1' }, { type: 'fail', error: 'upstream stopped' }],
[
{ type: 'send', id: 'm1' },
{ type: 'token', text: 'partial' },
{ type: 'fail', error: 'upstream stopped' },
{ type: 'retry' },
{ type: 'done' },
],
[{ type: 'send', id: 'm1' }, { type: 'cancel' }],
];
test('busy is true only while streaming, on every path', () => {
for (const path of paths) {
const end = run(path);
assert.equal(isBusy(end), end.kind === 'streaming', path.map((e) => e.type).join(' > '));
}
});
test('retry keeps partial text and increments the attempt', () => {
const end = run([
{ type: 'send', id: 'm1' },
{ type: 'token', text: 'Accessible ' },
{ type: 'fail', error: 'upstream stopped' },
{ type: 'retry' },
]);
assert.equal(end.kind, 'streaming');
if (end.kind === 'streaming') assert.equal(end.text, 'Accessible ');
if (end.kind === 'streaming') assert.equal(end.attempt, 2);
});
Run it with whichever TS runner you already have (npx tsx busy.test.ts), and wire it into CI next to your type check. One caveat worth saying out loud: automated accessibility scanners like axe will not catch this class of bug, because a stuck attribute is valid markup. The invariant test is doing the work a scanner cannot do.
A manual pass you fill in, not one I fill in for you
The stuck-attribute behaviour depends on the browser and the assistive technology, so I will not pretend my environment is yours. Here is the matrix I keep, with the transitions that matter most:
- Failure mid-stream, then retry — the single most important case, and the one that started this post.
- Failure with zero tokens received — does the error message still announce?
- Cancel, then send a new prompt — does the old reply go quiet without replaying?
- Two messages in flight — does the second one's busy state interfere with the first?
- Retry with a screen reader in browse mode — does the virtual cursor stay where the person left it?
Log the browser, OS, AT name and version, plus the exact transition that failed. "It doesn't work in Safari" is unactionable; "Safari 18, macOS, VoiceOver, fail after three tokens, then Retry" is a bug report.
Limits, and who should skip this
A few honest boundaries on the approach above. If your chat surface is a vendor widget, patching around its internals is worse than filing upstream with this reproduction attached. If your message list also contains the composer or other interactive controls, do not put aria-busy on that container at all, because you will suppress more than one region. And if you cannot run even one manual screen-reader pass before shipping, the invariant test buys you correctness of state but not confidence in behaviour.
One more trend-shaped note. On-device browser AI features make this mistake easier to ship, because there is no flaky network to force your catch branch and no server error to disagree with your state machine. Fewer failures in development, more silent regions in production.
What I check first next time
A short checklist that would have saved me a day, and that I now paste into streaming PRs:
- List every state and mark which one owns
aria-busy. - Find every function that writes that attribute. If there is more than one, consolidate.
- Force a failure after three tokens, then retry, before merging.
- Assert the invariant in a unit test, not in a comment.
- Do one manual screen-reader pass on the failure path specifically.
If you want to reproduce the failure without wiring a provider key, the fault-injecting endpoint plus a free model endpoint is enough of a setup to see the silent retry for yourself, and the free server option is plenty for a demo nobody else has to babysit. Tell me your browser, OS, and assistive-technology versions plus the exact transition that went quiet — I would rather extend the matrix than defend the fix.
Top comments (0)