The failure that made me rewrite my chat UI was small and embarrassing: I pressed Escape to cancel a streaming response, the text kept arriving for another two seconds, focus jumped back to the composer, and my screen reader announced nothing. Visually it looked "fine." For anyone navigating by keyboard or listening to the page, the app had silently changed state three times with zero acknowledgment.
Most streaming chat demos wire fetch straight into setState and bolt on a Stop button. That works on the happy path and collapses exactly where it matters: cancellation mid-token, retry after a network drop, and focus recovery after an error. So this article builds the thing properly — a typed state machine, a single-file runnable demo, and a QA matrix for keyboard and screen-reader testing — and then shows how to exercise it against a real model for free, because mocking the stream hides the exact failures you're trying to find.
The state table, before any code
Write the states down first. If you can't draw this table, your UI doesn't have defined behavior — it has accidents.
| State | Trigger | UI | Focus | Announcement |
|---|---|---|---|---|
idle |
initial / reset | composer enabled | composer | none |
streaming |
submit | Stop button visible, composer disabled | stays on composer | "Response started" (polite) |
cancelling |
Esc / Stop click | Stop disabled, "Cancelling…" | stays | none (too fast to matter) |
cancelled |
abort settled | partial text kept, Retry + Clear | Retry button | "Response cancelled. Partial answer kept." |
error |
network/HTTP failure | error region with Retry | error region | "Response failed" (assertive) |
retrying |
Retry activated | spinner on Retry | stays on Retry | "Retrying" |
done |
stream complete | composer re-enabled | composer | "Response complete" (polite) |
Three rules fall out of this table:
- Cancellation is a transition, not an event. Between "user asked to stop" and "stream actually stopped" there is real time. The UI must own that gap.
- Partial output is data, not garbage. A cancelled answer the user already read must not vanish.
- Every state change that isn't user-initiated gets announced. Silent state change is the accessibility bug, full stop.
A typed state machine
type StreamState =
| { kind: 'idle' }
| { kind: 'streaming'; controller: AbortController; text: string }
| { kind: 'cancelling'; controller: AbortController; text: string }
| { kind: 'cancelled'; text: string }
| { kind: 'error'; message: string; lastPrompt: string }
| { kind: 'retrying'; controller: AbortController; lastPrompt: string }
| { kind: 'done'; text: string };
type StreamEvent =
| { type: 'SUBMIT'; prompt: string }
| { type: 'TOKEN'; delta: string }
| { type: 'CANCEL' }
| { type: 'SETTLED_CANCEL' }
| { type: 'FAIL'; message: string }
| { type: 'RETRY' }
| { type: 'COMPLETE' }
| { type: 'RESET' };
function reduce(s: StreamState, e: StreamEvent): StreamState {
switch (s.kind) {
case 'idle':
if (e.type === 'SUBMIT')
return { kind: 'streaming', controller: new AbortController(), text: '' };
break;
case 'streaming':
if (e.type === 'TOKEN') return { ...s, text: s.text + e.delta };
if (e.type === 'CANCEL') {
s.controller.abort();
return { kind: 'cancelling', controller: s.controller, text: s.text };
}
if (e.type === 'FAIL')
return { kind: 'error', message: e.message, lastPrompt: '' };
if (e.type === 'COMPLETE') return { kind: 'done', text: s.text };
break;
case 'cancelling':
if (e.type === 'SETTLED_CANCEL') return { kind: 'cancelled', text: s.text };
if (e.type === 'TOKEN') return s; // late tokens after abort are dropped
break;
case 'cancelled':
if (e.type === 'RETRY')
return { kind: 'retrying', controller: new AbortController(), lastPrompt: '' };
if (e.type === 'RESET') return { kind: 'idle' };
break;
case 'error':
if (e.type === 'RETRY')
return { kind: 'retrying', controller: new AbortController(), lastPrompt: s.lastPrompt };
if (e.type === 'RESET') return { kind: 'idle' };
break;
case 'retrying':
if (e.type === 'TOKEN')
return { kind: 'streaming', controller: s.controller, text: e.delta };
if (e.type === 'FAIL') return { kind: 'error', message: e.message, lastPrompt: s.lastPrompt };
break;
case 'done':
if (e.type === 'RESET') return { kind: 'idle' };
break;
}
return s; // unhandled events are no-ops, never crashes
}
The important detail: unhandled (state, event) pairs are explicit no-ops. A TOKEN arriving after the user cancelled — which absolutely happens with real networks — is dropped by the machine, not by an if buried in a render function.
Runnable single-file demo
Save as chat-states.html and open it. It uses a mock token source so it runs offline; the mock deliberately supports artificial latency and random failure so you can rehearse the bad paths. Swap mockStream for a real endpoint later — the state machine doesn't change.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Streaming chat state machine demo</title>
<style>
body { font-family: system-ui; max-width: 42rem; margin: 2rem auto; }
#log { border: 1px solid #999; padding: 1rem; min-height: 6rem; white-space: pre-wrap; }
#log[data-state="error"] { border-color: #b00020; }
.controls { display: flex; gap: .5rem; margin-top: 1rem; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
#status { margin-top: .5rem; font-size: .875rem; color: #444; }
</style>
</head>
<body>
<h1>Streaming state machine demo</h1>
<div id="log" role="log" aria-label="Conversation" data-state="idle"></div>
<p id="status" aria-hidden="true"></p>
<!-- Live regions: assertive for failures, polite for progress -->
<div id="announce-polite" class="sr-only" aria-live="polite"></div>
<div id="announce-assertive" class="sr-only" aria-live="assertive"></div>
<form id="composer" class="controls">
<label class="sr-only" for="prompt">Message</label>
<input id="prompt" autocomplete="off" placeholder="Type anything, Enter to send">
<button type="submit">Send</button>
<button type="button" id="stop" hidden>Stop (Esc)</button>
<button type="button" id="retry" hidden>Retry</button>
<button type="button" id="clear" hidden>Clear</button>
</form>
<script type="module">
// --- Mock token source: 40–120ms per token, ~15% random failure ---
function mockStream(prompt, signal, onToken) {
const words = ("This is a simulated streaming answer to: " + prompt +
" — it keeps going so you have time to press Escape mid-stream.").split(" ");
let i = 0;
return new Promise((resolve, reject) => {
const tick = () => {
if (signal.aborted) return reject(new DOMException('aborted', 'AbortError'));
if (Math.random() < 0.03) return reject(new Error('simulated network drop'));
if (i >= words.length) return resolve();
onToken(words[i++] + " ");
timer = setTimeout(tick, 40 + Math.random() * 80);
};
let timer = setTimeout(tick, 300);
signal.addEventListener('abort', () => { clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError')); });
});
}
// reduce(...) from above goes here (omitted for brevity — paste it in)
const log = document.getElementById('log');
const status = document.getElementById('status');
const polite = document.getElementById('announce-polite');
const assertive = document.getElementById('announce-assertive');
const stopBtn = document.getElementById('stop');
const retryBtn = document.getElementById('retry');
const clearBtn = document.getElementById('clear');
const input = document.getElementById('prompt');
let state = { kind: 'idle' };
let lastPrompt = '';
function announce(el, msg) { el.textContent = ''; requestAnimationFrame(() => el.textContent = msg); }
function render() {
log.dataset.state = state.kind;
status.textContent = 'state: ' + state.kind;
stopBtn.hidden = state.kind !== 'streaming';
stopBtn.disabled = state.kind === 'cancelling';
retryBtn.hidden = !(state.kind === 'cancelled' || state.kind === 'error');
clearBtn.hidden = state.kind === 'idle' || state.kind === 'streaming';
input.disabled = state.kind === 'streaming' || state.kind === 'cancelling' || state.kind === 'retrying';
if ('text' in state) log.textContent = state.text;
}
function dispatch(e) {
const prev = state;
state = reduce(state, e);
if (state === prev) return;
if (state.kind === 'streaming' && prev.kind !== 'retrying') {
announce(polite, 'Response started');
runStream(state.controller);
}
if (state.kind === 'cancelling') {
// When the fetch promise rejects with AbortError we dispatch SETTLED_CANCEL.
}
if (state.kind === 'cancelled') {
announce(polite, 'Response cancelled. Partial answer kept.');
retryBtn.focus();
}
if (state.kind === 'error') {
announce(assertive, 'Response failed. ' + state.message);
retryBtn.focus();
}
if (state.kind === 'retrying') {
announce(polite, 'Retrying');
runStream(state.controller);
}
if (state.kind === 'done') {
announce(polite, 'Response complete');
input.focus();
}
render();
}
async function runStream(controller) {
try {
await mockStream(lastPrompt, controller.signal,
delta => dispatch({ type: 'TOKEN', delta }));
dispatch({ type: 'COMPLETE' });
} catch (err) {
if (err.name === 'AbortError') dispatch({ type: 'SETTLED_CANCEL' });
else dispatch({ type: 'FAIL', message: err.message });
}
}
document.getElementById('composer').addEventListener('submit', ev => {
ev.preventDefault();
lastPrompt = input.value || '(empty)';
input.value = '';
dispatch({ type: 'SUBMIT', prompt: lastPrompt });
});
stopBtn.addEventListener('click', () => dispatch({ type: 'CANCEL' }));
retryBtn.addEventListener('click', () => dispatch({ type: 'RETRY' }));
clearBtn.addEventListener('click', () => dispatch({ type: 'RESET' }));
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && state.kind === 'streaming') dispatch({ type: 'CANCEL' });
});
render();
</script>
</body>
</html>
Things to try, in order: submit and let it finish; submit and hit Escape after two tokens; hammer Escape five times fast (the machine absorbs it); keep retrying until the mock's random failure triggers twice in a row. Every path should keep focus somewhere sensible and announce what changed.
Why mock-only testing isn't enough
The mock fails on a coin flip. Real models fail on token 340 of a long answer, 9 seconds in, after your connection hiccupped once. That changes everything about perceived behavior: whether late tokens leak into the UI after cancel, whether the abort actually reaches the server, and whether retry re-sends the full prompt sanely.
This is where cost becomes the testing bottleneck — running dozens of cancel/retry cycles against a paid API to reproduce a race condition adds up, and it's exactly the kind of unstructured experimentation teams skip.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. For this rehearsal loop I've been pointing the same demo at MonkeyCode, which offers free model access and a free server option, so the failure-hunting runs don't need a budget approval. I swapped mockStream for a fetch with a readable stream body, kept reduce untouched, and throttled my browser to "Slow 4G" in DevTools. Two real bugs surfaced that the mock never produced: tokens arriving after the abort resolved (handled — the cancelling state drops them, which is why that no-op branch exists), and a retry that fired while the previous connection was still half-open.
If you want to run the same experiment, MonkeyCode is one way to get a real endpoint without a card; any OpenAI-compatible streaming endpoint works identically with this state machine.
QA matrix: what I actually test
| Scenario | Chrome + NVDA (Win) | Safari + VoiceOver (macOS) | Firefox, keyboard only |
|---|---|---|---|
| Esc cancels mid-stream, focus → Retry | |||
| Partial text preserved after cancel | |||
| Error announced assertively, focus → error region | |||
| Double-cancel is a no-op | |||
| Retry under "Slow 4G" throttle | |||
| No announcement storms during streaming (only start/complete) | |||
prefers-reduced-motion: no spinner-only state indication |
Fill in your own versions and note the exact transition that failed — "cancel→cancelled lost focus in Safari 17.x" is actionable; "a11y is broken" is not.
Limitations and who shouldn't use this
- The reducer shown is single-conversation. Multi-turn history needs a transcript model layered underneath; don't bolt it on ad hoc.
- A free tier is for rehearsal, not production. Availability and throughput of free options can change, and latency on a free server tells you little about your real provider's tail behavior. Re-run the matrix against your production endpoint before shipping.
- If your product streams structured data (tool calls, JSON patches) rather than prose, you need per-chunk validation states this article doesn't cover.
-
aria-liveon rapidly-changing token text is an anti-pattern — announce boundaries (start/cancel/error/complete), never the stream itself.
The takeaway: define the state table first, make unhandled events explicit no-ops, and test cancellation against a real stream on a throttled connection. The Stop button is the last thing you build, not the first.
Top comments (0)