I keep reviewing streaming AI chat demos that handle the happy path beautifully — tokens slide in, the markdown renders — and then fall apart the moment a user presses Escape mid-stream. Focus jumps to the top of the page. The screen reader says nothing. The "Retry" button re-sends the prompt but the half-finished answer is still on screen, so now there are two answers and nobody knows which one is real.
The reason is usually the same: the streaming was mocked with setTimeout or a canned array of chunks. A mock timer never disconnects at byte 1,204, never trickles one token per 900ms, and never hangs silently. So the failure states were never real, and neither was the UI built on top of them.
This post is a workflow for fixing that: a typed state machine for a streaming chat turn, a single-file demo you can run, and a QA matrix for the transitions that actually break. For a realistic backend I'll use a free hosted option so you can hit a real model over a real network without provisioning anything.
The state table first
Before any code, here's the contract the UI must honor. A chat turn is a state machine, not a boolean isLoading:
| State | Trigger | UI shows | Focus | Screen reader hears |
|---|---|---|---|---|
idle |
— | Composer enabled | Composer | — |
connecting |
submit | Stop button, spinner | Moves to Stop button | "Connecting" |
streaming |
first chunk | Growing answer, Stop button | Stays on Stop | "Response started" (once) |
cancelled |
Stop / Escape | Partial answer + "Cancelled. Retry?" | Moves to Retry | "Cancelled. Partial answer kept." |
failed |
network/HTTP error | Error region + Retry | Moves to Retry | "Response failed: {reason}. Retry available." |
retrying |
Retry clicked | Old partial answer is replaced, Stop returns | Back to Stop | "Retrying" |
done |
stream closes | Answer, composer re-enabled | Back to composer | "Response complete" |
Two rules matter more than the rest:
-
Retry replaces, never appends. The partial output from a
cancelledorfailedturn is shown as context but clearly marked, and a retry swaps it out atomically. Two competing answers is the confusing state. - Focus follows the action. When the user cancels, focus lands on the control they'll most likely want next (Retry), not wherever the browser feels like putting it.
Why a real endpoint changes what you build
With a timer mock, cancelled and failed are states you simulate with buttons. With a real endpoint, you discover the actual edge cases:
- The connection drops after the first byte but before the stream closes — is that
failedordone? (It'sfailed; the answer is incomplete and the user must know.) - The first token takes 4 seconds. If your UI has no
connectingstate distinct fromstreaming, the user sees a dead screen and hits submit twice. -
AbortController.abort()fires, but your fetch reader was mid-read()— you must handle the rejection explicitly or the turn hangs instreamingforever.
I didn't want to stand up infrastructure just to test this, so for the backend I used MonkeyCode, which offers free model access and a free server option — enough to point a fetch at a real streaming completion over a real network with real latency and real failure modes. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Everything below also works against any OpenAI-compatible streaming endpoint; the point is "real network", not the specific provider.
The demo: one file, typed states, real abort
Save this as index.html and serve it with any static server (npx serve .). Point ENDPOINT and MODEL at your streaming endpoint of choice.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Accessible streaming turn</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
#answer { min-height: 3rem; border: 1px solid #8884; border-radius: 8px; padding: 1rem; white-space: pre-wrap; }
#answer[data-state="cancelled"] { border-style: dashed; opacity: .75; }
#answer[data-state="failed"] { border-color: #c00; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }
.row { display: flex; gap: .5rem; margin-top: .75rem; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<h1>Streaming turn, done right</h1>
<!-- One polite live region for turn-level announcements -->
<p id="status" role="status" class="sr-only"></p>
<!-- Assertive only for failures -->
<p id="alert" role="alert" class="sr-only"></p>
<div id="answer" data-state="idle" aria-label="Assistant response"></div>
<form id="composer">
<label for="prompt">Prompt</label>
<div class="row">
<input id="prompt" autocomplete="off" required>
<button id="send" type="submit">Send</button>
<button id="stop" type="button" hidden>Stop</button>
<button id="retry" type="button" hidden>Retry</button>
</div>
</form>
<script type="module">
const ENDPOINT = 'https://your-server/v1/chat/completions'; // any OpenAI-compatible stream
const MODEL = 'your-model';
// ---- Typed state machine ----
/** @type {'idle'|'connecting'|'streaming'|'cancelled'|'failed'|'retrying'|'done'} */
let state = 'idle';
let controller = null;
let lastPrompt = '';
const $ = (id) => document.getElementById(id);
const answer = $('answer');
function setState(next, announce = '') {
state = next;
answer.dataset.state = next;
$('stop').hidden = !(next === 'connecting' || next === 'streaming' || next === 'retrying');
$('retry').hidden = !(next === 'cancelled' || next === 'failed');
$('send').disabled = (next === 'connecting' || next === 'streaming' || next === 'retrying');
if (announce) $('status').textContent = announce;
if (next === 'cancelled' || next === 'failed') $('retry').focus();
if (next === 'done') $('prompt').focus();
if (next === 'connecting') $('stop').focus();
}
async function runTurn(prompt) {
lastPrompt = prompt;
controller = new AbortController();
answer.textContent = '';
setState('connecting', 'Connecting');
let gotFirstChunk = false;
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, stream: true,
messages: [{ role: 'user', content: prompt }] }),
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
// parse SSE lines "data: {...}"
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:') || line.includes('[DONE]')) continue;
try {
const delta = JSON.parse(line.slice(5)).choices?.[0]?.delta?.content ?? '';
if (delta) {
if (!gotFirstChunk) { gotFirstChunk = true; setState('streaming', 'Response started'); }
answer.textContent += delta; // textContent, not innerHTML: stream is untrusted
}
} catch { /* partial JSON line, keep buffering */ }
}
}
if (!gotFirstChunk) throw new Error('Stream closed with no content');
setState('done', 'Response complete');
} catch (err) {
if (err?.name === 'AbortError') {
// keep partial text; dashed border marks it as incomplete
setState('cancelled', 'Cancelled. Partial answer kept. Retry available.');
} else {
$('alert').textContent = `Response failed: ${err.message}. Retry available.`;
setState('failed');
}
}
}
$('composer').addEventListener('submit', (e) => {
e.preventDefault();
const p = $('prompt').value.trim();
if (p && (state === 'idle' || state === 'done')) runTurn(p);
});
$('stop').addEventListener('click', () => controller?.abort());
$('retry').addEventListener('click', () => { setState('retrying', 'Retrying'); runTurn(lastPrompt); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && (state === 'streaming' || state === 'connecting')) controller?.abort();
});
</script>
</body>
</html>
Details worth stealing:
-
answer.textContent += delta, neverinnerHTML. Streamed model output is untrusted input. If you render markdown, sanitize after streaming completes. -
One
role="status"region, updated at transitions only. Announcing every token is an accessibility regression, not a feature. Screen reader users get "Response started" and "Response complete", and can navigate to the answer region when they want the content. -
Escape cancels, pointer-independently. The Stop button and Escape call the same
abort(). No keyboard trap, no pointer-only control. -
gotFirstChunkdistinguishesconnectingfromstreaming. This is the state your timer mock never taught you about, and it's the one users read as "the app froze".
QA matrix: the transitions to actually test
Run this against the real endpoint, with your browser, OS, and assistive tech versions noted:
| # | Scenario | How to trigger | Expected |
|---|---|---|---|
| 1 | Slow connect | Throttle network to "Slow 3G" in DevTools, submit |
connecting announced; Stop focusable; no double-submit possible |
| 2 | Cancel mid-stream | Press Escape at ~50% of answer | Partial kept with dashed style; focus on Retry; "Cancelled" announced |
| 3 | Retry after cancel | Activate Retry | Old partial replaced atomically; focus back to Stop |
| 4 | Drop mid-stream | DevTools → Network → Offline after first tokens |
failed, assertive announcement, focus on Retry |
| 5 | Empty stream | (Server-dependent; or kill request instantly) |
failed with "no content", not a silent done
|
| 6 | Screen reader pass | NVDA/Firefox + VoiceOver/Safari | Exactly two polite announcements per successful turn; zero token-by-token chatter |
| 7 | Reduced motion | prefers-reduced-motion: reduce |
No token-slide animation; content still readable |
Limitations and who shouldn't use this
- The demo parses SSE with a hand-rolled buffer. It's fine for testing your UI states; for production, use your provider's SDK or a hardened SSE parser — hand-rolled parsing is exactly where a malicious or malformed stream will bite you.
- Free hosted tiers — MonkeyCode's included — are great as a test target for latency and failure behavior, and I have no basis to claim anything about quotas, rate limits, model availability over time, or latency guarantees. Don't build a production dependency on a free tier, and don't benchmark against one and call the numbers representative.
- If your product's streaming contract includes tool calls, citations, or structured output, this single-
divanswer region is the wrong shape — you need per-part semantics, which is a longer post.
If you try the matrix and hit a transition that fails — especially #2 or #6 — I'd genuinely like to know: post your browser, OS, assistive tech version, and the exact state transition that broke. Those bug reports are how these patterns actually get fixed. And if you need a zero-setup endpoint to reproduce against, the MonkeyCode free server is one easy option to point the demo at while you iterate.
Top comments (0)