A colleague pressed Escape on a streaming answer, watched the text freeze mid-sentence, and then sat through eleven more seconds of a screen reader reading an answer they had already abandoned. She tabbed to Regenerate, and NVDA was still announcing the old conclusion on top of the new question. The visual UI was correct in every screenshot I took, which is exactly why this bug survived two code reviews.
So let me ask the question that reframes it: if the pixels stopped, why did the interface keep talking? Because "stopped" in a DOM sense and "stopped" in an assistive-technology sense are two different clocks, and my client only ever managed the first one. Below is the state machine I should have written first, the two announcer implementations side by side, and the reproduction harness that catches this in under a minute.
The state table I now write before the component
Every streaming surface I build gets this table before a single line of JSX. The right-most column is the part that usually goes missing, and it is the column that caused the bug.
| State | Network | Log nodes | Cancel control | Announcement policy |
|---|---|---|---|---|
idle |
none | empty | absent | none |
streaming |
open | appended by sentence | visible, enabled | one node per complete sentence |
cancelling |
aborting | frozen | disabled, labelled "Stopping" | silence |
cancelled |
closed | frozen + 1 final node | replaced by Regenerate | one short terminal message |
error |
closed | frozen + 1 error node | replaced by Retry | one message, includes retry hint |
retrying |
reopening | previous nodes kept | disabled | none |
Notice that cancelling exists at all. My original code jumped from streaming to cancelled in the same tick, which meant the queue of pending speech had nowhere to drain and no owner to clear it.
Step 1: reproduce it without a model
You do not need an API key to see this failure, and you should not want one while you are bisecting a rendering bug. The harness below emits awkward, bursty chunks on purpose, because tidy 20-token dribbles from a mock are precisely what hid the problem from me for a week.
// fake-stream.js — deterministic reproduction, zero network
export function fakeStream({ chunks, gapMs = 120, coalesceEvery = 6 }) {
const ctrl = new AbortController();
const out = (async function* () {
let pending = '';
for (let i = 0; i < chunks.length; i++) {
if (ctrl.signal.aborted) return; // stop producing, but nothing else
pending += chunks[i];
if (i % coalesceEvery === coalesceEvery - 1) {
yield pending; // a burst, not a token
pending = '';
}
await new Promise((r) => setTimeout(r, gapMs));
}
if (pending) yield pending;
})();
return { ctrl, out };
}
Run any static server against a single HTML file, no build step required, and open it on your phone too. The mobile VoiceOver pass is where this reproduces most reliably for me, because speech latency on a real device is not something a desktop simulator models well.
python3 -m http.server 8080
# then: open http://localhost:8080/demo.html
Try this exact sequence: start the stream, press Escape while the third sentence is being announced, then immediately Tab forward. If you hear an old sentence after the Tab, you have the bug.
Step 2: the buggy announcer, and its three defects
Here is close to what I shipped. It looks defensive, and every line of it is a mistake.
// announcer-buggy.js
const region = document.querySelector('#status'); // role="status"
let timer = null;
let fullText = '';
export function start() {
fullText = '';
timer = setInterval(() => {
region.textContent = fullText; // rewrite everything, every 400 ms
}, 400);
}
export function onChunk(text) {
fullText += text; // unbounded buffer
}
export function cancel(controller) {
controller.abort();
clearInterval(timer);
region.textContent = ''; // wiping does not un-queue speech
}
-
Every tick is a fresh announcement candidate. Assigning
textContentreplaces the node's contents, and live regions react to that mutation. The result is a region whose text grows forever and gets re-read from the start. - The buffer has no budget. A 900-word answer becomes 900 words of pending speech, and nothing in my code knew how much speaking time it had already committed to.
- Clearing is not cancelling. Emptying a live region removes text from the accessibility tree; it cannot recall utterances an assistive technology has already scheduled. Removing the node entirely does not help either, which is the part that surprised me most.
That third point is the generalisable lesson I keep coming back to: assertions about silence must be verified with a screen reader on, not by inspecting the DOM.
Step 3: sentence-boundary log with a bounded queue
The fix has three moving parts, and each one maps to a defect above. Append completed sentences as separate nodes into a role="log" container with aria-relevant="additions", never rewrite existing nodes, and cap how much speech may be outstanding at once.
// announcer.js — additions only, bounded speech debt
const log = document.querySelector('#response-log'); // role="log" aria-live="polite"
const MAX_QUEUE = 2; // ≈ 8 s of speech at 12 words each
let buffer = '';
let queue = [];
const node = (text) => {
const p = document.createElement('p');
p.textContent = text;
log.append(p);
return p;
};
const drain = () => {
for (const s of queue.splice(0)) node(s);
};
export function pushChunk(text) {
buffer += text;
const parts = buffer.split(/(?<=[.!?])\s+/);
buffer = parts.pop() ?? ''; // keep the incomplete tail
for (const s of parts) if (s.trim()) queue.push(s.trim());
if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
drain();
}
export function finish(reason) {
if (buffer.trim()) queue.push(buffer.trim());
buffer = '';
drain();
node(reason === 'cancelled'
? 'Response stopped by you.'
: reason === 'error'
? 'Response failed. Retry is available.'
: 'Response complete.');
}
The trade-off is real and I want to name it: dropping older queued sentences means a very fast reader hears a summary rather than every word. That is the correct trade for a live, interruptible surface, but if your product is a dictation tool or a reading app, you probably want no truncation and a slower, chunkier flush instead.
Step 4: put a real endpoint and a real network in the loop
My local harness lies to me in two predictable ways: it always delivers perfect UTF-8 boundaries, and it never buffers. Long, individually varying responses from an actual model are the cheapest way to break the sentence splitter, so for the last pass I run the same single-file demo against a hosted endpoint rather than my mock.
This is where MonkeyCode's free model access and free server option genuinely participate in the workflow rather than decorate it. I generate deliberately long answers on the free tier to stress the sentence splitter and the queue cap, then serve the client from the free server option so the browser, the network path, and the endpoint are not all on localhost — which is the only configuration where bursty delivery shows up for me. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
About availability, since this is the part readers get burned by: the project advertises a free token allowance (currently described as 10 million tokens) and a free server option, and I treat both as claims to re-check in my own dashboard before a sprint, not as constants. I have not measured its throughput, latency, or limits, and you should not read a quota or a hardware spec into this article. It is an open-source project as far as I know, so read the repository licence yourself before you build a dependency on it.
| Need | Local mock | Free endpoint + free server |
|---|---|---|
| Deterministic chunk boundaries | yes | no |
| Realistic bursty delivery | no | yes |
| Mobile VoiceOver over a real network | no | yes |
| Load or latency benchmarking | no | no, use a dedicated rig |
| Private or regulated source text | only local | no |
Who should not use this path. Teams that need a pinned SLA, a fixed privacy posture, or reproducible latency numbers should keep their own staging environment; anyone who wants to skip the manual screen-reader pass should not start, because no linter will catch this class of bug for you.
Step 5: the matrix I fill in before merging
These cells are expectations, not measured results — fill them in with the exact versions you ran. Any cell where reality disagrees is a bug report, not a taste question.
| Environment | Cancel mid-sentence | Cancel then Regenerate | Double Escape | Network error at 60% |
|---|---|---|---|---|
| Chrome + NVDA, Windows | one terminal node, silence after | no interleaving | no duplicate terminal node | one error node, Retry reachable by Tab |
| Safari + VoiceOver, macOS | same | same | same | same |
| Firefox + Orca, Linux | same | same | same | same |
| Safari + VoiceOver, iOS | same | same | same | same |
Two more rules that saved me from re-breaking this. First, the Cancel button and the terminal message must both be reachable with Tab alone, with no pointer involved, because the fastest path through the failure is keyboard-only. Second, the terminal node stays in the log rather than being swapped out on retry, so a user returning to the page can still tell what happened.
What I would tell the next person on call
Nothing in this bug was exotic; the whole failure came from treating an announcement as a side effect of rendering instead of as scheduled work with its own queue and budget. If you take one habit from this, take the state table: name cancelling as a state, decide who owns the speech queue, and write down what silence looks like before you write the component.
If you reproduce a version of this, please comment with the browser, OS, and assistive-technology versions plus the exact transition that failed — "Escape during sentence three, then Tab" is far more useful to me than "cancel is broken on Safari". And if you want a stress-test endpoint with a free allowance to try it against, the free tier on the server option is a reasonable place to start; just confirm the current quota in your own account first.
Top comments (0)