A low-vision user asked a question. The answer started streaming immediately. Every new token triggered a screen reader announcement, and the virtual cursor jumped from line to line while the model was still forming a sentence. The user heard fragments: "The... main... reason... for... the... fail..." and then the announcement stopped because a new chunk arrived and reset everything. They gave up, closed the tab, and didn't ask again.
That story comes from the exact problem I want to solve today: streaming AI responses are hostile to screen reader users when we treat every chunk as something to announce. The fix isn't to stop streaming—it's to create a buffer that respects how assistive technology actually reads.
To test my approach without spending money, I built a small chat UI using MonkeyCode's free models and free server. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server lets me simulate slow streams and random 429s, which is precisely when the accessibility symptoms get worst. You can reproduce everything below with any compatible AI endpoint; the principles are not vendor-specific.
The Problem: aria-live and Streams Don't Mix
Most chat UIs announce new tokens by putting the response inside an aria-live="polite" region. That works for short status messages like "Loading..." or "Sending...". But a model stream can emit 50 tokens per second. When you update an aria-live region that often, the screen reader will constantly interrupt itself, re-announce the partial text, and lose the narrative thread.
Worse, if you use aria-live="assertive" to force immediate reading, you guarantee the user cannot hear anything else on the page. The user is held hostage by the stream.
The core architectural mistake is conflating data availability with presentation priority. The fact that the text exists on the network does not mean the screen reader must read it immediately.
The Design: Buffered Announcements, Manual Playback
Instead of announcing every chunk, we introduce a three-state UX:
- Streaming – the model is still answering. The user hears a single polite announcement: "Generating response."
- Buffered – the model has finished (or paused). The user can press a button to read the response paragraph by paragraph.
-
Reading – a
SpeechSynthesisinstance reads the currently selected paragraph. The user can pause, resume, stop, or jump to the next paragraph.
This gives the user control over the pace. It also gives developers a clean mental model: the stream writes to a buffer; the user, not the network, controls when text enters the screen reader's queue.
Implementation: A useStreamBuffer Hook
I've extracted the core logic into a React hook. It exposes states, a start function, an abort function, and a readNextParagraph function. The stream is fetched through MonkeyCode's free server in my test, but the hook only cares about a ReadableStream<string>.
import { useState, useCallback, useRef, useEffect } from 'react';
type StreamState = 'idle' | 'streaming' | 'buffered' | 'error';
interface StreamBufferOptions {
fetchStream: (abortSignal: AbortSignal) => Promise<Response>;
onError?: (err: unknown) => void;
}
export function useStreamBuffer({ fetchStream, onError }: StreamBufferOptions) {
const [state, setState] = useState<StreamState>('idle');
const [paragraphs, setParagraphs] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const start = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState('streaming');
setParagraphs([]);
setCurrentIndex(0);
try {
const response = await fetchStream(controller.signal);
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Read the stream and split into paragraphs on double newlines.
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(/\n\n/);
buffer = parts.pop() ?? '';
if (parts.length > 0) {
setParagraphs((prev) => [...prev, ...parts.filter(p => p.trim())]);
}
}
if (buffer.trim()) {
setParagraphs((prev) => [...prev, buffer.trim()]);
}
setState('buffered');
} catch (err) {
if ((err as Error).name === 'AbortError') return;
setState('error');
onError?.(err);
}
}, [fetchStream, onError]);
const abort = useCallback(() => {
abortRef.current?.abort();
setState('buffered'); // keep what we already have
}, []);
const readNextParagraph = useCallback(() => {
setCurrentIndex((prev) => {
if (prev + 1 >= paragraphs.length) return prev;
return prev + 1;
});
}, [paragraphs.length]);
const readPreviousParagraph = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev));
}, []);
return { state, paragraphs, currentIndex, start, abort, readNextParagraph, readPreviousParagraph };
}
The hook intentionally does not put the visible text inside an aria-live region. Instead, the UI announces state changes and lets the user drive reading.
Making It Keyboard- and Screen-Reader-Friendly
The component wrapping the hook has three essential parts:
1. A status region (polite)
<div aria-live="polite" role="status">
{state === 'streaming' && 'Response is being generated.'}
{state === 'buffered' && 'Response is ready. Use the Read button to hear it.'}
{state === 'error' && 'An error occurred. Press Retry.'}
</div>
This gives the user a single, short announcement per state transition—not per token.
2. Reading controls with meaningful labels
<div>
<button onClick={abort} disabled={state !== 'streaming'}>Stop generating</button>
<button onClick={start} disabled={state === 'streaming'}>Regenerate</button>
<button onClick={readPreviousParagraph} disabled={currentIndex === 0 || paragraphs.length === 0}>Previous paragraph</button>
<button onClick={readNextParagraph} disabled={currentIndex >= paragraphs.length - 1 || paragraphs.length === 0}>Next paragraph</button>
</div>
All buttons are native <button> elements, so they are keyboard-operable by default. Focus order matches visual order. No ARIA roles are needed beyond the status region.
3. The reading pane with speechSynthesis
The visible text is in a normal <article> with no live region. When the user clicks "Read paragraph", we call speechSynthesis.speak() on that paragraph:
function speakParagraph(text: string) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1; // modify from settings if you want
speechSynthesis.cancel(); // don't queue overlapping speech
speechSynthesis.speak(utterance);
}
The controls also include a dedicated Pause/Resume button that calls speechSynthesis.pause() and speechSynthesis.resume(). This is separate from aborting the underlying network request; the stream may still be downloading while the user pauses reading.
Error States, Retry, and the Free Server
Testing against MonkeyCode's free server is useful precisely because it occasionally throws 429s and slow responses. I built a small error-insensitive fetchStream that simulates these conditions:
async function fetchWithRetry(url: string, maxRetries = 2): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url);
if (res.status === 429 && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
continue;
}
return res;
} catch (err) {
if (attempt >= maxRetries) throw err;
}
}
}
In the UI, when the hook's state becomes error, the status region announces it, and a Retry button appears. The retry preserves the user's original prompt and restarts the stream against the same free server endpoint. Because we keep the prompt in a separate state, the buffer never duplicates it—an important detail when you're iterating on a flaky endpoint.
Decision Table: When to Buffer vs. Stream-Aloud
| Context | Recommended pattern | Why |
|---|---|---|
| Short status updates (“Loading...”) |
aria-live="polite" immediately |
Low volume, critical timing |
| Long model responses with screen reader users | Buffer + manual playback | User controls pace, avoids interruption storms |
| Chat UI used by sighted users only | Plain streaming text without ARIA | No AT conflict |
| Real-time transcription (voice typing) |
aria-live="assertive" only on final segments |
Prevents partial-word chaos |
| Financial or medical advice | Always buffer and require explicit user action | Avoids forcing a misread critical number |
Test Plan and QA Matrix
Before shipping any chat UI, run this accessibility regression list:
- NVDA + Chrome (Windows): Start a stream. Confirm you hear exactly one announcement (“Response is being generated.”) and no per-token chatter.
- VoiceOver + Safari (macOS): Press the “Next paragraph” button while the stream is still loading. Confirm the button is reachable and the reading pane updates correctly.
- Keyboard only: Tab through all controls. Ensure focus never lands on a hidden element and the Stop button is reachable while streaming.
- Slow network (test via MonkeyCode's free server or DevTools throttling): Let the stream stall. The UI must remain responsive; the stop button must still work.
- Error injection: Force a 429. Verify the error is announced politely, the Retry button receives focus, and the prompt is not duplicated when retry succeeds.
Limitations and Who Shouldn't Use This
This buffered pattern trades interactivity for accessibility. If your audience is exclusively power users who want to see tokens appear in real time, a buffer can feel counterproductive. Also, speechSynthesis voices vary across operating systems; test with the default voice on your target platform.
The pattern also assumes the model output can be split into paragraphs. If you stream JSON or code, a different segmentation strategy is needed. And if your interface is safety-critical—medical, legal, financial—you should probably buffer the entire response before showing anything, not just before speaking it.
Finally, the hook above is deliberately minimal. In production you'll want to handle streaming JSON parsing, token usage tracking, and connection timeouts. But the core idea—let the user control the reading, not the network—is the part that matters.
I built this with the free tier on MonkeyCode's platform to iterate without burning through credits. Try the same workflow with your own endpoint: send a prompt that generates at least five paragraphs, turn on a screen reader, and see how the default approach fails. Then apply the buffer and test again. The difference will be audible.
Top comments (0)