DEV Community

babycat
babycat

Posted on

Test Your Streaming Chat UI's Cancel and Retry Against a Real Endpoint, Not a setTimeout

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:

  1. Retry replaces, never appends. The partial output from a cancelled or failed turn is shown as context but clearly marked, and a retry swaps it out atomically. Two competing answers is the confusing state.
  2. 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 failed or done? (It's failed; the answer is incomplete and the user must know.)
  • The first token takes 4 seconds. If your UI has no connecting state distinct from streaming, 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 in streaming forever.

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>
Enter fullscreen mode Exit fullscreen mode

Details worth stealing:

  • answer.textContent += delta, never innerHTML. 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.
  • gotFirstChunk distinguishes connecting from streaming. 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-div answer 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)