# AI chat replies read out in pieces as they stream in

Source: https://easeweb.dev/learn/streamed-replies
Topics: Live updates > AI replies that stream in are read once, not piece by piece (WCAG 4.1.3)
The fix: Keep the streamed text out of every live region by setting aria-live="off" on the conversation's role="log", and add a role="status" region beside it, empty from the start. When the reply is complete, write into that region once: either the whole reply, so it is read in one piece, or one short message that the assistant has answered, so people read the reply with their own keys.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA23 https://www.w3.org/TR/wai-aria-1.2/#aria-live https://www.w3.org/TR/wai-aria-1.2/#aria-busy https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

Fernhill, an outdoor gear shop, has a help page with an AI assistant on it: a heading, Ask our assistant, a conversation that opens with a greeting, and a question field with a Send button. The conversation is a `role="log"`, so that new messages are read out without anyone leaving the field, as a chat's should be. A screen reader user types "Can I return a tent I've used?" and presses Enter.

The reply streams in, as AI replies do: a few words appear, then a few more, until two sentences fill the bubble. A sighted user reads along as it grows. The screen reader user hears their own question read back, then "Assistant:", then "Yes, within", "30 days", "even if you", "have used it", each said as a separate message as it lands. The answer they asked for arrives as a run of scraps, and when the stream stops they still don't know whether they heard all of it, so they leave the field and read the reply again from the start.

## Why it fails

A `role="log"` is a live region: its additions are announced, politely, in the order they arrive. That is right for a chat where each message arrives whole. Here the script adds each chunk of the stream to the reply as it comes, and every chunk is an addition, so every chunk becomes a message. How a screen reader paces such a run differs between screen readers and browsers. A bug report on one open-source AI chat, tested with NVDA and Firefox, puts it plainly: "every single token or 'chunk' appended to the DOM triggers a new announcement".

Success criterion 4.1.3 asks that status messages can be presented to people by assistive technology without receiving focus, and its Understanding page says the point is to make people aware of the change "in a way that doesn't unnecessarily interrupt their work". The same page describes a related case: where only part of a message is coded as the change, people may hear only that part, "which may not be sufficient information to provide context for the user". The reply here can be found, but what the live region presents is fragments, not the message.

## Who is affected

Streaming is designed for people watching the text: it shows that the assistant is working and lets them start reading at once. The longer the reply, the more pieces it is heard in, and a reply with a list or a few steps in it can be read out as dozens of separate scraps.

## What automation and AI miss

A scan of the page sees a named `role="log"` holding a greeting, a labelled field and a button, and finds nothing wrong: it is the pattern W3C describes for a chat history. The failure only exists while a reply streams, which means asking the assistant a question, and an audit doesn't do that on a live site. EaseWeb's audit raises a review lead when a live conversation sits beside a text field in something named as an AI assistant, so that a person asks it a question; it can't tell from the page whether replies stream. AI coding assistants asked to make a chat accessible can add `role="log"` or `aria-live="polite"` to the conversation, which is how this pattern is made. Component libraries for AI chat ship it too: the conversation component of Vercel's AI Elements, for one, is a `role="log"` that each reply streams into, as of October 2026. Only a screen reader, used while a reply streams, shows what is said.

## Before: a live log that each chunk is added to

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">Ask our assistant</h2>
  <div class="messages" role="log" aria-label="Conversation">
    <p class="message from-assistant"><span class="visually-hidden">Assistant: </span>Hi! Ask me about orders, sizes or returns.</p>
  </div>
  <form class="ask-form">
    <label for="ask-input">Your question</label>
    <input id="ask-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'Assistant: ' : 'You: ';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() yields the reply a few words at a time, as the model's API sends it.
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
});
```

The conversation is a log with a name, every message says who wrote it, and focus stays in the field. The stream is where it fails:

- Each chunk is appended to the reply inside the live log, so each one is announced as a message of its own: "Yes, within", then "30 days", then "even if you".
- Nothing is announced when the reply is complete, so people can't tell from what they heard whether the answer has ended or is still coming.

## After: the finished reply, read once

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">Ask our assistant</h2>
  <div class="messages" role="log" aria-label="Conversation" aria-live="off">
    <p class="message from-assistant"><span class="visually-hidden">Assistant: </span>Hi! Ask me about orders, sizes or returns.</p>
  </div>
  <p id="reply-status" class="visually-hidden" role="status"></p>
  <form class="ask-form">
    <label for="ask-input">Your question</label>
    <input id="ask-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const replyStatus = document.getElementById('reply-status');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'Assistant: ' : 'You: ';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  replyStatus.textContent = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() yields the reply a few words at a time, as the model's API sends it.
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
  // Complete: the whole reply, with who wrote it, is read once.
  replyStatus.textContent = reply.textContent;
});
```

1. Add `aria-live="off"` to the conversation. It stays a named log that people can find and read back through, but what is added to it is not announced while focus is elsewhere, so the stream is silent.
2. Add an empty, visually hidden `p` with `role="status"` beside the conversation, outside it. It is in the page before the first question, so writing into it is a change that is announced, politely.
3. When the last chunk has arrived, write the whole reply into it. The reply's own text starts with the hidden "Assistant: ", so the screen reader reads "Assistant: Yes, within 30 days, even if you have used it…" once, in one piece, when it finishes what it was saying.
4. Clear the region when a question is sent, so a reply that happens to match the last one word for word is still a change, and is still read.

This fixes the Before and passes 4.1.3: the reply is presented once, as a whole message, without moving focus. The longer the reply, the longer the wait before anything is heard, and the more there is to listen to in one go.

## After: one short message when the reply is ready

```html
<section class="assistant" aria-labelledby="assistant-title">
  <h2 id="assistant-title">Ask our assistant</h2>
  <div class="messages" role="log" aria-label="Conversation" aria-live="off">
    <p class="message from-assistant"><span class="visually-hidden">Assistant: </span>Hi! Ask me about orders, sizes or returns.</p>
  </div>
  <p id="reply-status" class="visually-hidden" role="status"></p>
  <form class="ask-form">
    <label for="ask-input">Your question</label>
    <input id="ask-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const messages = document.querySelector('.messages');
const replyStatus = document.getElementById('reply-status');
const form = document.querySelector('.ask-form');
const input = document.getElementById('ask-input');

function addMessage(from, text = '') {
  const message = document.createElement('p');
  const sender = document.createElement('span');
  message.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'assistant' ? 'Assistant: ' : 'You: ';
  message.append(sender, text);
  messages.append(message);
  return message;
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const question = input.value;
  input.value = '';
  replyStatus.textContent = '';
  addMessage('you', question);
  const reply = addMessage('assistant');
  // streamReply() yields the reply a few words at a time, as the model's API sends it.
  for await (const chunk of streamReply(question)) {
    reply.append(chunk);
  }
  // Complete: say so once, and leave the reply to be read where it is.
  replyStatus.textContent = 'The assistant has answered.';
});
```

1. Add `aria-live="off"` to the conversation, as in the first fix, so the stream is silent and the log can still be read back.
2. Add the same empty, visually hidden `role="status"` region beside it.
3. When the last chunk has arrived, write one short message into it: "The assistant has answered." People then read the reply in the conversation with their own reading keys, line by line or a list item at a time, and can stop and go back, as with any other text on the page. The reply is the last message in the conversation, just before the form, so reading back from the field reaches it just after the field's label and the short message.
4. Clear the region when a question is sent, so the same message is announced again for the next reply.

This fixes the Before and passes 4.1.3: the end of the reply is announced once, without moving focus, and the reply is read whole, by the person, when they choose.

## Which option to use

Both keep the stream silent and say one thing when it is complete; they differ in what that one thing is. Reading the finished reply suits an assistant whose answers are a sentence or two, like a shop's help assistant: people hear the answer without lifting a finger, as they would hear a message in any chat. It reads the reply as one flat run of words, though, so a list, a table or a block of code loses its shape, and a reply of several paragraphs is a long thing to sit through with no way back except reading it again. The short message suits an assistant whose answers are long or structured: people hear that the answer is ready and read it the way they read everything else, with headings, lists and code kept, at their own pace. Choose by the length and shape of your typical answer.

## Implementation decisions

**Not a fix: `aria-busy`.** ARIA describes `aria-busy="true"` for this case: set it on the log while the reply streams, set it back to `false` when it is complete, and assistive technology may hold the changes and read them as one. The specification says "may", so a screen reader is free to read the changes as they come, and a log marked busy can still be read in pieces. It does no harm alongside either fix, but it is not a fix on its own.

**Not a fix: making the reply bubble atomic.** `aria-atomic="true"` on the bubble, or rewriting the whole reply into it on each chunk, makes each change read the reply again from its first word. People hear the opening phrase over and over as the reply grows.

**The wait before the first word.** Many assistants show a "Thinking…" indicator before the reply starts. Write it into the status region once, if the wait is long enough to need saying, and never put a repeating indicator or one that changes its text every second inside a live region. The wait itself is covered in [loading spinners that screen readers never announce](/learn/loading-states).

**Stopped and failed replies.** A Stop button ends the stream early; write "Stopped." or the partial reply into the status region, so people know the answer is not complete. When the stream fails, write what failed and what to do into the same region ("The assistant couldn't answer. Try again."). Put the write in a `finally` block, or after a `try`, so it runs however the stream ends.

**Several replies at once.** An assistant that answers in two or three messages, or that runs tools and reports each step, still says one thing per finished message. Don't announce each tool step, and don't stack messages in the status region: it holds the latest one.

**Polite, not assertive.** A reply is never urgent enough to cut into what people are reading. Don't use `role="alert"` or `aria-live="assertive"` for it.

**Don't move focus to the reply.** It does get the reply read, but it takes the caret out of the field, where people are already typing their next question.

**Chats that never announce at all.** A chat whose conversation is in no live region, and whose replies are never said, is a different failure: see [chat widgets that never announce their messages](/learn/chat-widgets). Its fixes use `role="log"` for replies that arrive whole; once replies stream, add the silencing and the status region from this guide.

## Verify the fix

1. Before sending anything, inspect the DOM: the conversation is not a live region while replies stream into it (a `role="log"` has `aria-live="off"`), the reply bubbles have no `aria-live` or `aria-atomic`, and an empty `role="status"` region sits outside the conversation, not hidden with `display: none` or `aria-hidden`.
2. With a screen reader running and the mouse put aside, ask a question that gets a reply several sentences long: nothing is read while it streams; once it is complete, the whole reply or the short message is read once, and focus is still in the field.
3. Ask the same question again: the reply, or the message, is read again. Stop a reply part way, and cut the network during another: each says once that it stopped or failed.
4. From the field, read back through the conversation with the screen reader's reading keys: the complete reply is there, read with who wrote it.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the chat component, the streaming code or the component library changes.

## Limits

How a screen reader paces a run of live region changes, and whether it reads, queues or skips them, differs between screen readers, browsers and releases, so test the stream with the ones your users use. Auditors may record a reply read in pieces under 4.1.3, since what is presented is not the message, or as a usability finding, since the log does expose each change; either way, people can't follow the answer. This guide covers one assistant on one page; an assistant drawn in a vendor's frame can only be changed through the vendor's settings, and the questions to ask them are in [chat widgets that never announce their messages](/learn/chat-widgets). Scores, tickers and timers that change by themselves are a different case, covered in [countdown timers that screen readers keep reading out](/learn/countdown-timers).

## What screen readers do (tested by hand by EaseWeb)

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`role="log"`: A polite live region for entries added in order, such as a chat: what is added is announced, and what was there is not repeated.

| Screen reader | Browser and system | Down Arrow | Say all | Streamed in |
| --- | --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested | Not tested |

`role="log" aria-live="off"`: Turns the announcements of a region off: ARIA asks for its changes not to be presented unless focus is inside it.

| Screen reader | Browser and system | Streamed in |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
