Have you ever watched a chat spinner keep spinning after DevTools already marked the fetch as complete? I have seen that exact spinner, and the keyboard path felt worse than the visual lie. I tabbed to Stop, pressed Enter twice, and nothing in the composer then moved at all. The screen reader stayed completely silent, so I assumed the remote model was still thinking hard.
It was not thinking in any useful sense, because the response body had already closed itself. The streamed body finished with zero tokens and without an error payload I could parse. That failure is not a stall, because a stall still owns an open reader on the client. This is a quiet close: the connection is gone, aria-busy stays true, and cancel is already dead.
Why did Stop refuse a perfectly timed Enter? Because disabled={!isStreaming} flipped false in the same tick that cleared the reader. The turn looked busy in CSS, looked finished in the network waterfall, and looked like nothing at all to VoiceOver.
The interaction that actually failed
I was wiring a browser agent prototype to a remote origin so I could test cancel and retry without a paid key. The visual chrome looked industrious. The waterfall said the request was done. VoiceOver on macOS said nothing after the first “Generating” announcement. Escape did not abort, because the AbortController had already settled, and the control was disabled.
Here is the failure in the order I felt it:
- Focus sits in the composer after I submit a short prompt with Enter.
- A CSS spinner replaces the status text, and no later live update ever arrives.
- The remote origin closes the stream without an
errorevent and without a token. - Stop becomes disabled, Retry never appears, and the live region never speaks a dead-end.
- Tab order still includes the composer, which feels editable, even though we still treat the turn as in flight.
If you only test a local mock that always yields tokens, you will never see this path. Quiet close shows up on free remote origins, flaky proxies, and HTTP 200 responses whose bodies end empty.
I guessed the wrong layer first
I did what most of us do under time pressure, and I guessed in the wrong layer first. I blamed a React remount that dropped focus. I blamed a polite live region that swallowed the update. I blamed the model for being “slow” when it was already gone.
Those guesses were reasonable, and they were still wrong. The assistant message node never remounted during the hang. The live region was polite, empty, and honest about having nothing to say. The model never produced a byte of data:.
What actually failed was a four-bucket state machine: idle, streaming, complete, and error. A quiet close is none of those buckets. The fetch fulfilled, response.body existed, and the first read() returned { done: true }. We mapped that to complete with empty text. Complete disabled Stop. Empty text did not look like an error. The only diagnostics lived in console.debug.
The state table I wish I had drawn on day one
Draw this before you style a spinner. Complete must mean the reader has something to read.
| State | Tokens | Connection | Stop | Retry | Live announcement | Focus target |
|---|---|---|---|---|---|---|
idle |
n/a | none | hidden | hidden | none | composer |
submitting |
0 | opening | enabled | hidden | “Submitting prompt” | status |
streaming |
waiting or growing | open | enabled | hidden | throttled status, never raw tokens | status or Stop |
quiet_close |
0 | closed | hidden | enabled | “The model closed without a reply” | diagnostics heading |
network_error |
any | closed | hidden | enabled | specific error text | diagnostics heading |
aborted |
any | closed | hidden | enabled | “Generation cancelled” | composer |
complete |
> 0 | closed | hidden | hidden | “Reply complete” | composer |
Notice quiet_close is not complete. Complete means a person can read a reply. Quiet close means the turn ended with nothing to read and nothing to blame. If your UI only has success and thrown errors, this row falls on the floor.
Enter in composer
-> submitting (aria-busy=true, Stop enabled)
-> streaming (chunks arrived)
-> complete (busy=false, focus composer)
-> read done, tokens === 0
-> quiet_close (busy=false, focus diagnostics, Retry)
-> fetch throw
-> network_error
-> abort()
-> aborted (focus composer)
The analogy I use now is a train that arrives with no cars. The platform board should not say “On time.” It should say “This service ended empty,” and it should point you at a station desk you can actually walk to. The diagnostics panel is that desk. It is not a toast, and it is not a console line.
Minimal reproduction
The file below is a labeled demo, not a vendor SDK, and it does not parse every SSE dialect. It fakes a reader that can finish empty, which is the quiet-close path I kept missing in mocks that always streamed “Hello.” Point fetch at a real remote origin later if you want production-shaped headers. Keep the state names stable when you do that swap.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used their free model access and free server option as a no-key remote origin while I reproduced the empty-body close, not as a magic accessibility fix. The panel below is still your UI problem even if the origin is local.
<!doctype html>
<meta charset="utf-8" />
<title>Quiet-close diagnostics demo</title>
<style>
:root { font: 16px/1.45 system-ui; color: #102a43; }
body { max-width: 40rem; margin: 2rem auto; }
#status[aria-busy="true"] { outline: 2px dashed #1864ab; outline-offset: 4px; }
#diag:focus { outline: 3px solid #d9480f; }
button:disabled { opacity: .45; }
pre { background: #f1f3f5; padding: 1rem; overflow: auto; }
</style>
<form id="form">
<label for="prompt">Prompt</label>
<textarea id="prompt" rows="3">Explain quiet close in one sentence.</textarea>
<div>
<button type="submit" id="send">Send</button>
<button type="button" id="stop" hidden>Stop</button>
</div>
</form>
<p id="status" aria-live="polite"></p>
<section id="panel" hidden aria-labelledby="diag">
<h2 id="diag" tabindex="-1">Stream diagnostics</h2>
<dl>
<dt>Origin</dt><dd id="d-origin"></dd>
<dt>Request id</dt><dd id="d-id"></dd>
<dt>Last event</dt><dd id="d-event"></dd>
<dt>Decoded bytes</dt><dd id="d-bytes"></dd>
</dl>
<button type="button" id="copy">Copy diagnostics</button>
<button type="button" id="retry">Retry same prompt</button>
<button type="button" id="edit">Edit prompt</button>
</section>
<pre id="reply" hidden></pre>
<script>
const $ = (id) => document.getElementById(id);
/** @typedef {'idle'|'submitting'|'streaming'|'quiet_close'|'network_error'|'aborted'|'complete'} TurnState */
let state = 'idle';
let controller = null;
let lastPrompt = '';
let diag = { origin: 'demo://quiet-close', requestId: '', lastEvent: '', bytes: 0 };
function announce(text) { $('status').textContent = text; }
function setBusy(busy) { $('status').setAttribute('aria-busy', busy ? 'true' : 'false'); }
function render(next) {
state = next;
const streaming = next === 'submitting' || next === 'streaming';
$('send').disabled = streaming;
$('stop').hidden = !streaming;
$('stop').disabled = !streaming;
$('panel').hidden = !(next === 'quiet_close' || next === 'network_error');
$('reply').hidden = next !== 'complete';
setBusy(streaming);
}
function showDiag() {
$('d-origin').textContent = diag.origin;
$('d-id').textContent = diag.requestId;
$('d-event').textContent = diag.lastEvent;
$('d-bytes').textContent = String(diag.bytes);
$('diag').focus();
}
async function fakeQuietClose(signal) {
// Labeled demo: a 200-shaped stream that ends on the first read.
await new Promise((r) => setTimeout(r, 400));
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
return { type: 'quiet_close', lastEvent: 'done', bytes: 0 };
}
async function runTurn(prompt) {
lastPrompt = prompt;
controller = new AbortController();
diag = { ...diag, requestId: crypto.randomUUID(), lastEvent: 'open', bytes: 0 };
render('submitting');
announce('Submitting prompt');
try {
render('streaming');
announce('Waiting for tokens');
const result = await fakeQuietClose(controller.signal);
diag.lastEvent = result.lastEvent;
diag.bytes = result.bytes;
if (result.type === 'quiet_close') {
render('quiet_close');
announce('The model closed without a reply. Diagnostics are focused.');
showDiag();
return;
}
render('complete');
announce('Reply complete');
$('prompt').focus();
} catch (err) {
if (err.name === 'AbortError') {
render('aborted');
announce('Generation cancelled');
$('prompt').focus();
return;
}
diag.lastEvent = 'throw';
render('network_error');
announce('The request failed. Diagnostics are focused.');
showDiag();
}
}
$('form').addEventListener('submit', (e) => {
e.preventDefault();
const prompt = $('prompt').value.trim();
if (!prompt || state === 'submitting' || state === 'streaming') return;
runTurn(prompt);
});
$('stop').addEventListener('click', () => controller && controller.abort());
$('retry').addEventListener('click', () => runTurn(lastPrompt));
$('edit').addEventListener('click', () => { render('idle'); $('prompt').focus(); });
$('copy').addEventListener('click', async () => {
const text = Object.entries(diag).map(([k, v]) => `${k}: ${v}`).join('\n');
await navigator.clipboard.writeText(text);
announce('Diagnostics copied');
});
</script>
Three bugs stacked, which is why the UI felt haunted instead of merely empty. First, we treated done: true as success even when the decoder had never emitted a token. Second, Stop used disabled={!isStreaming}, so the control vanished in the same frame the reader died. Third, failure details went to console.debug, so keyboard and screen-reader users had no object they could land on.
What I changed in the real control
I kept Stop enabled through submitting and streaming, then I swapped it for Retry without a hidden gap. I moved origin, request id, last event, and decoded byte count into a section with a heading that is programmatically focused. I throttled the polite live region so it speaks state names, not a token firehose that drowns Escape. I set aria-busy only while the reader is actually owned.
Practical rules I now refuse to violate:
- Diagnostics are a
sectionlabelled by a heading, never arole="alert"toast that steals context. - The heading uses
tabindex="-1"so I can move focus without inserting a surprise tab stop. - Copy, Retry, and Edit prompt are real
buttonelements, not clickabledivs with keydown folklore. - Pointer users and keyboard users get the same recovery actions. Hover is not a requirement.
- Token text never goes into an assertive live region. Assertive firehoses make cancel announcements impossible to hear.
- A new retry gets a new request id. Re-announcing the whole transcript is its own accessibility bug.
When you later replace fakeQuietClose with fetch, keep the mapping explicit. read() returning { done: true } with zero decoded bytes is quiet_close. A thrown TypeError is network_error. AbortError is aborted. HTTP 200 is not a personality trait of the model. It is just a status code.
async function readSse(response, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let lastEvent = 'open';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
lastEvent = 'done';
break;
}
bytes += decoder.decode(value, { stream: true }).length;
lastEvent = 'chunk';
}
if (bytes === 0) return { type: 'quiet_close', lastEvent, bytes };
return { type: 'complete', lastEvent, bytes };
}
Keyboard and screen-reader regressions I now run
I do not trust a green network waterfall anymore. I run these in order, and I write down the exact transition that failed.
- Keyboard-only send: Tab to the textarea, type, Enter, confirm focus is not lost on submit.
- Stop while submitting: activate Stop before the first chunk; expect
abortedand composer focus. - Quiet close: force an empty 200 body; expect diagnostics heading focus and a Retry tab stop.
- Screen reader: confirm one polite announcement for the dead-end, not a replay of the prompt.
- Copy diagnostics: activate Copy, then paste into the textarea; expect a short “copied” announcement.
- Retry: activate Retry; expect a new request id and no second copy of the old transcript.
- Edit prompt: activate Edit prompt; expect focus in the textarea with the original text intact.
- Pointer independence: repeat 3–7 with only the keyboard. If a path needs a mouse, it is not done.
Environment matrix
Please reproduce with versions, not vibes. The transition I care about is streaming → quiet_close with zero tokens.
| Environment | What I check |
|---|---|
| Chrome latest, Windows, NVDA | Diagnostics heading takes focus; NVDA speaks the dead-end once |
| Firefox latest, Windows, NVDA | Stop does not disable before Retry is present |
| Safari latest, macOS, VoiceOver | Rotor still finds the diagnostics section
|
| Keyboard only, any browser | Tab order is composer → Send/Stop → diagnostics actions |
| Reduced motion | Spinner is optional; the text status still changes |
If your quiet close only “works” in a mouse-driven Chromium session, you have a demo, not a control.
Limitations, and who should not copy this blindly
This approach detects a heuristic: the stream ended and we decoded nothing. It cannot explain why the origin stayed silent, and it will mislabel a model that legitimately answers with an empty string as a transport failure. It also does not replace server-sent error events, request tracing, or a real SSE parser for your vendor.
Do not ship this as production incident response if you need contractual uptime, authenticated billing, or guaranteed model routing. Do not hide legal or privacy failures inside the same panel as a quiet close. Do not use an assertive live region “so users notice”; they will notice so hard that they cannot hear Stop. Teams that never test with a screen reader should not claim the panel is accessible just because a heading exists in the DOM.
If you want a no-key remote origin to reproduce the empty-body path I hit, MonkeyCode’s free model access and free server option are enough to stand up the same demo. Bring a browser, an assistive-technology version, and the exact streaming → quiet_close transition, not a screenshot of a happy-path spinner.
Top comments (0)