A teammate opened our new streaming assistant with VoiceOver, typed a short prompt about the water cycle, and pressed Enter. Instead of one answer, they heard hundreds of fragments queue up in the speech buffer, and Escape stopped the network request without stopping the voice. When the stream finally ended, the useful content was buried under a minute of stuttering, duplicated words that no keystroke could skip.

I had tested that same panel with a screen reader for about ninety seconds, on a deliberately slow stream, and it sounded acceptable. That gap between acceptable on a slow stream and unusable on a fast one is exactly where this bug lived. It took me longer than I want to admit to notice that my own test conditions were the defect.

Below is the retrospective: the symptom, two wrong hypotheses I burned an afternoon on, the root cause in the DOM, the announcement policy that replaced it, and the matrix I run before calling any streaming interface accessible.

The state table I should have written first

Streaming interfaces have more states than the visual design suggests, and team conversations about them usually stop at loading and error. Writing the table first would have exposed the missing rows in about ten minutes, because a state with no status text is a state a screen reader user cannot perceive at all.

State Visual surface Status node (role="status") Focus
idle composer, empty transcript empty textarea
requested send disabled, stop visible Generating response. unchanged
streaming tokens appended to transcript empty by default, last sentence if opted in unchanged
cancelled stopped note plus Regenerate Response stopped. unchanged
failed error panel with Retry Response failed. Retry is available. unchanged unless nothing rendered
complete full answer, word count Response complete. 312 words. unchanged

The original build had no streaming row, which is why nobody ever asked what should be announced while text arrives. It also had no cancelled row, so stopping a response was silent for the one group of users who cannot glance at a spinner. Two missing rows, two accessibility bugs, and zero lines of ARIA to blame.

Two hypotheses that cost me an afternoon

Hypothesis one: the live region was duplicated. I found a wrapper with aria-live="polite" around the answer container that also had aria-live="polite" on it, which looked like the obvious culprit. Removing the outer attribute changed nothing measurable, because nested polite regions do not multiply announcements the way folklore suggests. That was two hours gone to a plausible but wrong theory.

Hypothesis two: the DOM churn was faster than the assistive technology could drain. I throttled rendering to one update every 60 milliseconds and the stutter got slightly less dense. The queue depth barely moved, because the number of mutations had only dropped by a factor of four on a stream that produced hundreds of tokens. A speech queue that grows linearly with token count does not care about your frame budget.

What finally reframed it was reading the markup the way an assistive technology sees it. The streaming container was the live region, and every appended token was a subtree change inside it. I had built a machine that fired one announcement candidate per token, then blamed the screen reader for announcing them.

Reproduce it in one file

Save the snippet below as index.html, serve it with python3 -m http.server 8000, and open it in Safari with VoiceOver enabled via Cmd+F5. This is the version that flooded the queue.

<!-- before: the streaming container is also the live region --> <article id="answer" aria-live="polite"> <div id="answer-body"></div> </article> <script type="module"> const body = document.querySelector('#answer-body'); for await (const token of stream) { body.append(token); // one mutation per token, one announcement candidate each } </script> 

On a fast stream you will hear fragments pile on top of each other, and pressing Escape will not silence what is already queued. That last part is the detail most fixes miss: a page cannot cancel speech an assistive technology has already accepted. You can only stop producing new mutations, which means the bug has to be prevented upstream rather than cleared afterwards.

The fix: one quiet transcript, one loud status node

The repair rests on four rules I now apply to every streaming surface. They are boring, they are testable, and they survived contact with three assistive technologies.

  1. Keep the transcript in the accessibility tree, but take it out of every live region so arriving text never announces itself.
  2. Use exactly one status node, and insert it in the DOM before the first update, because some assistive technologies ignore regions created lazily.
  3. Announce boundaries by default: start, stop, failure, completion. Content is something the user navigates to, not something that interrupts them.
  4. Offer an explicit per-user policy for people who genuinely want to follow along sentence by sentence.

That fourth rule is the interesting one, so here is the trade-off table I used when arguing for the default.

Announcement policy What the user hears Best for Cost
per token (the bug) every fragment, unsorted nothing unusable, uncancellable
per sentence, throttled to 1.5s rolling complete sentences users who want live progress repeated words if not atomic
boundaries only (default) start, stop, failure, complete most readers silence during long generations
boundaries plus timed progress the above, plus one update at 10s slow models on slow networks one extra interruption

Here is the self-contained page with the fix applied. The mock stream is fake on purpose, so you can run it offline and swap the generator later.

<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Streaming status demo</title> </head> <body> <main> <h1>Ask the assistant</h1> <a href="#answer-heading" id="jump">Jump to response</a> <form id="composer"> <label for="prompt">Your question</label> <textarea id="prompt" rows="3">Summarise the water cycle.</textarea> <button type="submit">Send</button> <button type="button" id="stop" hidden>Stop generating</button> </form> <!-- exactly one live node, present from the start --> <p id="status" role="status"></p> <article id="answer" aria-labelledby="answer-heading"> <h2 id="answer-heading" tabindex="-1">Response</h2> <div id="answer-body"></div> <button type="button" id="again" hidden>Regenerate</button> </article> </main> <script type="module"> const statusEl = document.getElementById('status'); const bodyEl = document.getElementById('answer-body'); const form = document.getElementById('composer'); const stopBtn = document.getElementById('stop'); const againBtn = document.getElementById('again'); let controller = null; // Identical strings do not re-announce, so clear before setting. function announce(text) { statusEl.textContent = ''; requestAnimationFrame(() => { statusEl.textContent = text; }); } async function* fakeStream(signal) { const words = ('Water evaporates, rises, cools and condenses into clouds. ' + 'It then returns to the surface as rain or snow. ' + 'This loop redistributes heat around the planet.').split(' '); for (const word of words) { if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); await new Promise((r) => setTimeout(r, 60)); yield word + ' '; } } async function send() { controller = new AbortController(); bodyEl.textContent = ''; againBtn.hidden = true; stopBtn.hidden = false; announce('Generating response.'); const node = document.createTextNode(''); bodyEl.append(node); try { for await (const chunk of fakeStream(controller.signal)) { node.data += chunk; // visual only, never a live region } const words = bodyEl.textContent.trim().split(/\s+/).filter(Boolean).length; announce(`Response complete. ${words} words.`); } catch (err) { if (err.name === 'AbortError') { announce('Response stopped.'); againBtn.hidden = false; } else { announce('Response failed. Retry is available.'); againBtn.hidden = false; } } finally { stopBtn.hidden = true; controller = null; } } form.addEventListener('submit', (event) => { event.preventDefault(); if (!controller) send(); }); stopBtn.addEventListener('click', () => controller?.abort()); againBtn.addEventListener('click', () => send()); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && controller) { controller.abort(); } }); </script> </body> </html> 

Two details in that file matter more than they look. The visible text lives in a single text node updated with node.data, so the DOM churns without spawning hundreds of spans. And the stop button stays in the tab order while generating, because a control that only appears in the corner of the screen is not a control for keyboard users.

Why role="status" and not another aria-live

role="status" already carries an implicit polite live region and atomic semantics, so adding aria-live="polite" beside it is redundant noise. Atomic behaviour is what makes a sentence replacement read as one phrase instead of a diff. If you hand-roll aria-live, you also have to remember aria-atomic, which is the detail people drop first under deadline.

Announcements that repeat still need clearing

When the same failure happens twice, assigning the same string to the status node produces no mutation, so no announcement. The clear-then-set in a requestAnimationFrame is deliberate, and it is the smallest reliable trick I have found across engines. Skip it and your second identical error becomes a silent one.

Keyboard and focus rules that survived the fix

  • Escape aborts only while a request is in flight, and it never moves focus on its own.
  • The transcript heading is focusable with tabindex="-1" so the jump link can target it, but nothing autofocuses it on completion.
  • Failures that render no content move focus to the error panel; failures with partial content do not steal focus from the composer.
  • Regenerate appears in the tab order after a stop or failure, giving a pointer-free path back.
  • Focus order stays composer, stop, transcript, regenerate, in that reading order, with no positive tabindex anywhere.

Why my mock lied, and the second environment I now use

My first mock emitted one word every 120 milliseconds, forever, which is the friendliest cadence a stream can have. Hosted endpoints stall for three seconds, dump forty tokens, then stall again, and each burst is a different number of announcement candidates. A test that only exercises the polite cadence is a test that certifies the wrong thing.

So I keep two environments now: the offline mock above for reproducibility, and a hosted endpoint for burstiness. MonkeyCode's free model access and free server option are one way to get that second environment without a paid key; the project states a free allowance of 10 million tokens and a free server option, and I have taken both as operator-supplied claims rather than benchmarked numbers.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I am not claiming it is faster, cheaper, or better than anything else here, because I did not measure that and it is not the point. The point is that a second chunk cadence broke my interface assumptions in a way my own mock never would, and any hosted endpoint will do the same job. If you want that second data point, point the demo at whatever endpoint you already have and watch the status node rather than the transcript.

The QA matrix I run now

This table describes the checks I plan and run against the fix, not a conformance result for any product. Treat it as a script to execute, and record assistive technology versions next to every pass or fail.

Environment Scenario Expected
VoiceOver + Safari, macOS start a generation one announcement, no trailing chatter
VoiceOver + Safari, macOS Escape mid-stream request aborts, Response stopped. announced once
NVDA + Chrome, Windows bursty stream of 40 tokens no fragment announcements
NVDA + Chrome, Windows identical error twice second error still announced
TalkBack + Chrome, Android completion word count announced, focus unmoved
keyboard only Tab order during streaming stop reachable, no focus jump

Who should not copy this approach

The boundaries-only default is wrong for answers that take a minute or more, because silence starts to look like a crash and users cancel healthy requests. Add exactly one timed progress announcement around ten seconds, and stop there.

It is also a poor fit for code-heavy responses, where a screen reader reading braces and semicolons is worse than nothing. Offer a copy button, a short summary, and headings the user can jump between instead.

Finally, if you cannot test with actual assistive technology users, ship the boundaries but do not claim conformance. A quiet transcript is a defensible engineering decision; an accessibility statement you cannot back with a test session is a liability.

Reproduce it and tell me what your AT does

Load the file, start a stream, press Escape halfway through, then start a second one and force the failure path. If your speech queue still floods, I want the details: browser and version, operating system, assistive technology and version, and the exact transition that failed. That last item is the one that turns a report into a fix.