DEV Community

babycat
babycat

Posted on

Escape Stopped the Pixels but Not the Voice: Debugging a Streaming Live Region That Kept Talking

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  1. Every tick is a fresh announcement candidate. Assigning textContent replaces 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.
  2. 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.
  3. 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.');
}
Enter fullscreen mode Exit fullscreen mode

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)