DEV Community

babycat
babycat

Posted on

My Screen Reader Read Three Sentences Behind: Debugging a Live-Region Backlog in Streaming Chat

My Screen Reader Read Three Sentences Behind: Debugging a Live-Region Backlog in Streaming Chat

At 1:12 a.m. on a Tuesday, a QA note landed in my inbox with a twelve-second screen recording attached to it. Visually, the chat reply had finished: the typing dots were gone, the Stop button was disabled, and the text sat quietly on screen. Audibly, the screen reader kept speaking words from the middle of that same message for another eleven seconds. The visual UI said "done," and the audio channel said "still catching up."

Was the model still streaming? No — the network panel showed the response body had closed cleanly a moment earlier. Was the screen reader broken? Also no, and that assumption cost me an hour before I dropped it. I had simply handed the assistive technology a job it could never finish in real time.

This post is the debugging retro: the symptom table, three hypotheses, an instrumented reproduction, the real root cause, and the commit-boundary fix I now use in every streaming chat interface.

The one-line rule before we start: never mutate a polite live region once per token. Buffer tokens, then commit complete sentence or paragraph additions into a single role="log" container.

The symptom table I write before touching any code

Ask what the interface promises before you open a debugger, because streaming text runs two clocks at the same time. One clock is visual paint, and the other one is the speech queue that nobody instruments. Most teams measure the first and assume the second follows it.

Moment What sighted users see What the screen reader should do
Stream starts Dots appear, Stop enables Nothing, or one short status message
Tokens arrive Words fill in gradually Nothing per token
Sentence ends Same visual, no change Announce the new sentence once
Long unpunctuated stretch Text keeps growing Announce on a time budget, not per character
Stream ends Dots vanish, controls settle Finish the last addition, then stop
Error Inline error appears Announce the error text once
User cancels Text freezes, "Stopped" appears Announce the stop once, leave focus alone

Observed behavior in that recording: the announcement queue lagged about three sentences behind at completion, and the gap grew with response length. When I swapped a long answer for a two-line answer, the lag nearly disappeared, which was the first genuinely useful clue.

Three hypotheses, one instrumented reproduction

I listed the plausible causes before writing any fix, because guessing at assistive-technology behavior is how you ship a second bug on top of the first.

  1. Every token mutated a polite live region, so mutations queued faster than speech could drain them.
  2. aria-atomic="true" sat on the transcript container, forcing whole-region re-announcements.
  3. The framework re-rendered the message node on each chunk, so the AT lost the position it had been tracking.

To separate them, I counted DOM mutations inside the log container and compared that cadence with what I actually heard. The instrumentation is short, and it works in any framework because it observes the DOM instead of your component tree:

const log = document.querySelector('[role="log"]');
let additions = 0;
let textChanges = 0;
const started = performance.now();

new MutationObserver((records) => {
  for (const record of records) {
    if (record.type === "childList") additions += record.addedNodes.length;
    if (record.type === "characterData") textChanges += 1;
  }
}).observe(log, { childList: true, subtree: true, characterData: true });

setInterval(() => {
  const elapsed = Math.round(performance.now() - started);
  console.log(`${elapsed}ms additions=${additions} text=${textChanges}`);
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Read the output against three signatures, because each signature points at a different fix:

  • Additions per second roughly equal to tokens per second means hypothesis 1, and the queue is the problem.
  • Additions spiking while the visible text stays identical means hypothesis 3, which is remount churn.
  • A single addition that re-announces the entire message means hypothesis 2, and you should inspect the accessibility tree rather than your JSX.

How I hosted the reproduction

I wanted a real token stream instead of a hardcoded fixture, so I ran the whole reproduction on MonkeyCode, an open-source project that offers free model access and a free server option. Its page advertises a free token allowance, 10M tokens at the time of writing, so treat that figure as time-sensitive and confirm the current terms yourself. I did not need a paid tier to serve a static demo page plus a small streaming proxy.

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

One security note that matters more than the hosting choice: keep provider keys on the server side behind your proxy, even in a throwaway demo, and never inline them into the client bundle.

Root cause: a queue that speaks at 1× and receives at 8×

Polite live regions do not drop text, and that is exactly why they fail here. They schedule it. When you mutate the region faster than the speech engine can drain its queue, the backlog accumulates monotonically for as long as the stream runs. The stream then ends while the queue is still draining, which produces the precise symptom in that recording: a finished UI with a voice still reading the middle of the message.

Hypothesis 3 makes the same problem worse in a subtler way. If your renderer replaces the message node on each chunk, the region's tracked content resets constantly, and some AT and browser pairings react by re-reading earlier text. That is why the audio sometimes jumped backwards instead of simply lagging.

Why not just set the live region to off and announce once at the end? For a two-line answer that is fine, but for a thirty-second answer the user sits in silence with no idea whether the request is alive. Coalescing gives you progress without giving up a readable cadence.

The fix: commit at boundaries, never per token

Start with a transcript that is one live region, not many, and keep a separate status element for stream state. role="log" already implies polite behavior with additions and text as the relevant changes, so declaring the attributes is mostly documentation for the next reader.

<div id="transcript" role="log" aria-live="polite" aria-relevant="additions"></div>

<p id="stream-status" role="status" class="sr-only">Idle</p>
Enter fullscreen mode Exit fullscreen mode

Then buffer incoming tokens and commit them as single DOM additions when a boundary occurs. The thresholds below are heuristics I tuned by listening, not laws of nature, so change them after you measure your own responses.

const BOUNDARY = /([.!?…]["')\]]?(?=\s|$)|\n{2,})/;
const MAX_CHARS = 120;
const MAX_WAIT_MS = 900;

class LogCommitter {
  #buffer = "";
  #timer: number | null = null;
  #lastCommit: number;

  constructor(private readonly log: HTMLElement, private readonly now = () => performance.now()) {
    this.#lastCommit = this.now();
  }

  push(token: string): void {
    this.#buffer += token;
    const hitBoundary = BOUNDARY.test(this.#buffer);
    const tooLong = this.#buffer.length >= MAX_CHARS;
    const tooSlow = this.now() - this.#lastCommit >= MAX_WAIT_MS;
    if (hitBoundary || tooLong || tooSlow) this.commit();
    else this.#schedule();
  }

  finish(): void {
    this.commit();
  }

  #schedule(): void {
    if (this.#timer !== null) return;
    const wait = Math.max(0, MAX_WAIT_MS - (this.now() - this.#lastCommit));
    this.#timer = window.setTimeout(() => {
      this.#timer = null;
      this.commit();
    }, wait);
  }

  commit(): void {
    if (this.#timer !== null) {
      clearTimeout(this.#timer);
      this.#timer = null;
    }
    const text = this.#buffer.trim();
    this.#buffer = "";
    this.#lastCommit = this.now();
    if (!text) return;

    const chunk = document.createElement("span");
    chunk.textContent = `${text} `;
    this.log.append(chunk); // one DOM addition → one polite announcement
  }
}
Enter fullscreen mode Exit fullscreen mode

Wiring it up stays boring, which is the point:

const transcript = document.querySelector<HTMLElement>("#transcript")!;
const committer = new LogCommitter(transcript);

for await (const token of tokenStream) {
  committer.push(token);
}
committer.finish();
Enter fullscreen mode Exit fullscreen mode

Two details make this hold together in practice. First, never repeat the message text inside the status region, or every commit gets announced twice. Second, the visual typing cursor can live outside the transcript as a decorative element, but the committed text must stay in one place so reading order never changes under the user.

The state table your implementation actually needs

Write this table down, because it forces you to decide what the log does in every state instead of only the happy one.

State Buffer DOM write Live region effect Focus
Idle Empty None Silent Composer
Streaming Growing None Silent Composer, unchanged
Committing (boundary hit) Flushed Append one span One polite addition Unchanged
Complete Flushed Append final span One addition, status updates Unchanged
Error Flushed or dropped Error text in the same log One plain-language error Stays in composer
Cancelled Flushed "Stopped by you" appended One addition Wherever the user left it

The 20-minute manual test

Automated mutation counts tell you cadence, but only listening tells you comprehension. This is the sequence I run before calling a streaming UI done:

  1. Open the deployed reproduction and enable your screen reader with speech at your normal rate.
  2. Request an answer of at least three sentences and start a stopwatch when the visible text stops changing.
  3. Stop the stopwatch on the last spoken word, and write the gap into the matrix below.
  4. Request an answer that contains a long unpunctuated string, to exercise the 900 ms time budget.
  5. Cancel mid-stream, and confirm the log announces the stop once without moving focus.
  6. Force an error by killing the proxy, then confirm the error is announced once and in plain language.
  7. Repeat everything with only the keyboard, and watch for reading position jumps.
Browser OS AT and version Gap at completion Re-read earlier text? Notes
Chrome Windows NVDA (your version) ___ s yes / no
Chrome macOS VoiceOver (your version) ___ s yes / no
Safari iOS VoiceOver (your version) ___ s yes / no
Firefox Windows NVDA (your version) ___ s yes / no

Fill this with your own numbers rather than mine, because default speech rates, punctuation handling, and queue behavior differ per pairing. A gap that stays flat as responses get longer is a passing result, and a gap that grows with length means you are still committing too often.

What this approach does not fix, and who should skip it

Coalescing is a cadence fix, not a universal accessibility fix, and I would rather be honest about the edges:

  • It does not equalize AT behavior. Braille display users may prefer a different commit cadence than speech users.
  • It does not fit code-heavy responses, where sentence punctuation inside a code block is meaningless. Commit at fenced-block boundaries instead.
  • It adds complexity you do not need if your responses arrive complete, or if you are rendering short deterministic strings.
  • It is a poor fit for live captioning, where latency matters more than chunk size, so use a dedicated policy for that case.
  • A free demo server is not production infrastructure. Do not put real user data, long-lived secrets, or compliance-relevant logs on one.

The thresholds here are my heuristics after listening to real responses, and they will need retuning for fast speech rates, verbose models, or languages where sentence boundaries look different.

Reproduce it with me

If your streaming UI finishes visually before it finishes audibly, I would like the exact numbers: browser, OS, assistive technology and version, plus the specific transition where the queue fell behind. Those three data points turn a vague "screen reader feels laggy" report into something reproducible in twenty minutes. If you want to run the reproduction without standing up your own model proxy, MonkeyCode's free model access and free server option are what I used for mine, though check the current terms before you plan around them. Fix the cadence first, and the interface will finally agree with itself.

Top comments (0)