DEV Community

babycat
babycat

Posted on

Streaming Errors Need an Announcement Policy, Not Just a Retry Button

I broke my own chat UI last week in the most boring way possible: I throttled my network to "Slow 3G," sent a message, watched three tokens stream in, and then walked my laptop out of Wi-Fi range.

Visually, the result was fine. The stream stopped, an error card appeared, a Retry button showed up. But when I repeated the test with VoiceOver on, the experience was nonsense. The screen reader was mid-sentence reading the partial response when the error region fired, then the live region that announces new tokens went silent forever, and when I hit Retry, focus jumped back to the input while the new response started streaming into a message bubble I was no longer focused on. A sighted user sees a story: it failed, you retried, it recovered. A screen reader user hears three unrelated fragments.

The fix wasn't more ARIA. It was deciding, ahead of time, what gets announced at each failure point — an announcement policy — and wiring it to a typed state machine.

The failure isn't "stream failed." It's where it failed.

A streaming response can die at very different moments, and each one needs a different spoken outcome:

Failure point Visual state What the user needs to hear Focus behavior
Before first token Empty bubble, spinner "The request failed before the response started. Nothing was lost." Stay on composer; Retry reachable next in tab order
Mid-stream, partial tokens Truncated message + error card "The response stopped partway. N characters were received." Announce, do not move focus; Retry inside the error card
After last token, before done event Looks complete, isn't confirmed "The response may be incomplete." Same as mid-stream
Retry in progress Spinner on the errored bubble "Retrying." once — not per token Focus untouched
Retry succeeded Stream resumes or replaces "The response continued." then normal token announcements Focus untouched
Retry exhausted Final error card "Retry failed again." + the actual next action Move focus to the error card's action row

The uncomfortable rows are the middle ones. If you announce "retrying" and then immediately resume token announcements, a screen reader user can't tell whether the new tokens continue the old thought or replaced it. That distinction has to be said, not implied.

A minimal policy engine

The state machine itself is small. The policy is a pure function from (failurePoint, attempt) to an announcement string, so it's testable without a browser:

type FailurePoint =
  | "before-first-token"
  | "mid-stream"
  | "pre-completion";

type StreamState =
  | { phase: "idle" }
  | { phase: "streaming"; chars: number }
  | { phase: "failed"; at: FailurePoint; chars: number; attempt: number }
  | { phase: "retrying"; at: FailurePoint; chars: number; attempt: number }
  | { phase: "exhausted"; at: FailurePoint; attempt: number }
  | { phase: "done"; chars: number };

function announcement(
  event: "fail" | "retry-start" | "retry-resume" | "retry-replace" | "exhausted",
  s: Extract<StreamState, { phase: "failed" | "retrying" | "exhausted" }>
): string {
  switch (event) {
    case "fail":
      if (s.at === "before-first-token")
        return "The request failed before the response started. Nothing was lost.";
      if (s.at === "pre-completion")
        return "The response may be incomplete. Retry is available.";
      return `The response stopped partway after ${s.chars} characters. Retry is available.`;
    case "retry-start":
      return "Retrying.";
    case "retry-resume":
      return "The response continued where it stopped.";
    case "retry-replace":
      return "The previous partial response was replaced by a new one.";
    case "exhausted":
      return `Retry failed again after ${s.attempt} attempts. You can start a new message or try again later.`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  1. "resume" vs "replace" are different announcements. If your retry sends the prompt again and streams a fresh answer, say so. If it continues the same stream (e.g., via a resumable endpoint), say that instead. Screen reader users build a mental model from announcements; lying to the model produces confusion you can't debug later.
  2. Announce once per transition, never per token. Token-by-token updates during normal streaming should already be throttled (a common pattern: aria-live="polite" on a visually hidden summary that updates every ~500ms, not on the raw token container). Error announcements reuse the same summary node so they queue politely instead of interrupting mid-word.

The live region wiring:

<!-- One polite summary node for ALL stream announcements -->
<div id="stream-status" class="sr-only" role="status" aria-live="polite"></div

<!-- The error card is NOT a live region. It's focusable content. -->
<div class="error-card" role="alertdialog" aria-labelledby="err-title" hidden>
  <p id="err-title">Response interrupted</p>
  <button type="button" data-action="retry">Retry</button>
  <button type="button" data-action="dismiss">Dismiss</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Note the split: role="status" announces; the error card offers actions. Combining both into one role="alert" element is how you end up with a button that's announced but unreachable by keyboard until the user hunt-and-pecks for it.

Reproducing real failure timing, not setTimeout timing

Here's the part where most test setups cheat. A mock that fails after exactly 200ms never produces the ugly cases: failure after the third token, failure during the announcement of a previous failure, failure while the user's screen reader is still speaking the partial text.

For this article I pointed the UI at a real streaming model so token pacing was honest. I used the free model access on MonkeyCode for the upstream — the point wasn't the model, it was that real token inter-arrival times (bursty, irregular, occasionally pausing mid-sentence) are what expose announcement pile-ups. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Then, to make failures reproducible instead of weather-dependent, I ran a tiny failure-injecting proxy between the UI and the model endpoint. MonkeyCode's free server option was enough to host it — the proxy is about 40 lines and needs no GPU, it just forwards a stream and murders it on a schedule:

// proxy.ts — run with: FAILURE_MODE=mid-stream deno run --allow-net proxy.ts
// Injects a real network-level abort at a deterministic point.
const mode = Deno.env.get("FAILURE_MODE") ?? "mid-stream";
const UPSTREAM = Deno.env.get("UPSTREAM_URL")!;

Deno.serve({ port: 8787 }, async (req) => {
  const upstream = await fetch(UPSTREAM, {
    method: "POST",
    headers: req.headers,
    body: req.body,
  });
  if (!upstream.body) return new Response("no body", { status: 502 });

  const { readable, writable } = new TransformStream();
  const writer = writable.getWriter();
  const reader = upstream.body.getReader();
  let chunks = 0;
  const killAfter = mode === "before-first-token" ? 0 : mode === "pre-completion" ? 12 : 3;

  (async () => {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (chunks++ === killAfter) {
        // Abrupt termination: no close frame, no error JSON. Like real Wi-Fi loss.
        await writer.abort(new Error("injected network failure"));
        return;
      }
      await writer.write(value);
    }
    await writer.close();
  })();

  return new Response(readable, {
    headers: { "content-type": "text/event-stream" },
  });
});
Enter fullscreen mode Exit fullscreen mode

Now FAILURE_MODE=mid-stream gives you the same failure on every run, in CI or on your desk, with real model pacing upstream of the cut. Your Playwright test can assert on announcements deterministically:

test("mid-stream failure announces char count and keeps focus", async ({ page }) => {
  await page.goto("/chat");
  await page.getByRole("textbox", { name: /message/i }).fill("hello");
  await page.keyboard.press("Enter");

  const status = page.getByRole("status");
  await expect(status).toContainText(/stopped partway after \d+ characters/);

  // Focus must NOT have been yanked away from the composer
  await expect(page.getByRole("textbox", { name: /message/i })).toBeFocused();

  // Retry lives in the error card and works by keyboard
  await page.getByRole("alertdialog").getByRole("button", { name: "Retry" }).click();
  await expect(status).toContainText("Retrying.");
});
Enter fullscreen mode Exit fullscreen mode

Run the suite three times — once per FAILURE_MODE — and you've covered every row of the policy table.

QA matrix (the part that actually catches regressions)

Announcement behavior varies wildly across browser/AT pairs, so "it worked in Chrome with VoiceOver" is not a test result. Minimum matrix I'd run before calling this done:

Environment What to verify
Chrome + NVDA (Windows) role="status" queues after the partial response instead of clipping it
Safari + VoiceOver (macOS) Retry-resume announcement isn't swallowed when tokens arrive in the same frame
Firefox + NVDA alertdialog doesn't double-announce the status text
VoiceOver (iOS, touch) Error card actions reachable by swipe without leaving the thread
No AT, keyboard only Tab order: composer → error card Retry → Dismiss → history; no focus trap
prefers-reduced-motion No animated "reconnecting" shimmer as the only busy indicator

Limitations and who shouldn't use this

  • This assumes you control the stream protocol. If your provider's SDK hides chunk boundaries, you can't reliably distinguish "mid-stream" from "pre-completion" — you'll have to collapse those two rows into one vaguer announcement.
  • Resumable streams are rare in practice. Most APIs will make you re-send the prompt, so your real-world announcement is usually "replaced," and your UI should visually diff or version the bubble so the announcement matches what changed.
  • Don't put the error card inside the live region to "save a node." You'll re-announce it on every retry tick.
  • If your product is a non-interactive log viewer (CI output, telemetry), none of this applies — an error line in the log is the whole policy. And if your retry requires a page reload, fix that first; no announcement policy survives a reload.

If you want to try the setup, the free model tier and free server on MonkeyCode were sufficient for the proxy-plus-real-upstream rig above — but any streaming endpoint plus the proxy will do; the policy table is the actual artifact.

If you reproduce this and hit a transition my table misses, I'd genuinely like to know: drop your browser, OS, and screen reader versions plus the exact transition that announced wrong (or didn't announce) in the comments.

Top comments (0)