DEV Community

babycat
babycat

Posted on

Streaming Chat Needs a Content-Type Guard, Not Infinite aria-busy

I tabbed into a throwaway chat shell, typed a short prompt, and pressed Enter. The composer went quiet, the Stop control took focus, and the status text froze on Generating. A polite live region never moved past that frozen word, so the transcript felt abandoned. Have you seen a stream UI that looks busy while the network request already finished?

The Network panel showed a completed document with a closing time, not an open event stream. So why did aria-busy stay true on the conversation log after the bytes had already landed? Nothing in the transcript was still downloading, and the Stop control had nothing left to abort. That mismatch is the whole incident, and it is easy to miss if you only watch the spinner.

What failed, and what this is not

This walkthrough is a debugging retrospective for a keyboard-operated chat, not a design-strategy essay. I wanted an interface that stayed understandable through loading, error, cancellation, and recovery without a mouse. The shell already had a Stop control, a retry path, and a client watchdog, yet none of those honest states fired. I was staring at a generating label that described a stream that had already ended.

I am not replaying a cancelled reader that never released the composer after Stop. I am not rebuilding a live heading tree inside streaming Markdown as the tokens land. This failure happened before the token parser saw a single data: line, so those later defenses never woke up. Those older bugs are real, but they are not this one, and mixing them hides the MIME mistake.

If your phase table includes connecting and streaming but omits a non-SSE failure, an HTML body will impersonate a live stream. Screen readers then sit on Generating until somebody finally closes the tab in frustration. Is that an accessibility bug, a protocol bug, or a state machine that never learned to fail? It is both kinds of failure, and the chat UI still owns the recovery path.

The state table I needed on day one

Phase Composer aria-busy on the log Status text Focus Stop
idle enabled and named false silent composer absent
connecting aria-disabled true Connecting to the model. Stop present
streaming aria-disabled true Generating a response. Stop present
failedNonSse enabled and named false The server returned a page, not a stream. composer absent
failedHttp enabled and named false Status-specific error composer absent
cancelled enabled and named false Generation cancelled. composer absent
complete enabled and named false Response complete. composer absent

Draw that table before you style another spinner. The missing row is usually failedNonSse, and every later control depends on it. If Stop only disappears in CSS, keyboard users still sit on a control that cannot abort a finished page.

Symptom to root cause

I treated the silence like any other stuck state machine in the browser. The sequence below is the reusable debugging method, and you can run it on any chat shell. Keep notes in this order so you do not jump straight into another spinner tweak.

  1. Confirm the pointer-independent path. I submitted with Enter, not a mouse click, and Tab remained inside the composer until that submit.
  2. Record focus immediately after submit. Focus moved to Stop, which is correct only while a stream is abortable.
  3. Record announcements. The log exposed aria-busy="true" and a polite Generating status, then nothing else spoke.
  4. Inspect the network timing. The request completed, and Content-Type was text/html; charset=utf-8.
  5. Read the body as text. It was a generic error page from the remote process, not server-sent events.
  6. Trace the parser. The client still awaited data: lines on a ReadableStream reader, so the phase never left streaming.

The analogy I keep using is a station board that stays on Arriving because the station posted a closed notice instead of a timetable. The board is not slow, and the passengers are not impatient for no reason. It is reading the wrong document type, and the spoken status keeps promising a train that already vanished.

Why did a finished fetch lie to the interface so easily? I keyed the happy path off response.ok and a non-empty body, not off Content-Type. Shared and free servers often answer with HTML for cold starts, proxy errors, or login gates. A 200 HTML page is enough to trap aria-busy until reload.

Decision table for the first header

Content-Type (essence) Next phase Why
text/event-stream streaming Tokens may arrive.
text/html failedNonSse This is a page, not a stream.
application/json failedNonSse unless you intentionally chose unary JSON A chat log should not parse a JSON error as Markdown.
missing or empty failedNonSse Do not guess SSE.

If you only branch on HTTP status, you will miss the 200 HTML case. That case is the one that feels haunted, because the network waterfall looks finished while the log still claims work. Have you ever trusted response.ok and then watched the composer stay locked for no visible reason?

A typed phase machine, not a boolean

Booleans such as isLoading cannot tell a screen reader what happened. You need a phase that can die as failedNonSse without pretending tokens arrived. The labeled example below is a proposal you can paste into a single-file TypeScript shell.

type ChatPhase =
  | { kind: "idle" }
  | { kind: "connecting" }
  | { kind: "streaming"; buffer: string }
  | { kind: "complete"; buffer: string }
  | { kind: "failedNonSse"; detail: string }
  | { kind: "failedHttp"; status: number; detail: string }
  | { kind: "cancelled" };

function isAbortable(phase: ChatPhase): boolean {
  return phase.kind === "connecting" || phase.kind === "streaming";
}
Enter fullscreen mode Exit fullscreen mode

The composer should be aria-disabled only while isAbortable(phase) stays true. Stop should render only in those abortable phases, never as leftover chrome. Every other phase must restore a named, focusable textarea so a keyboard user can edit the failed turn. Does your hook still set native disabled={true} after a finished HTML response? That is the leak, because native disabled drops the control from the tab order.

Guard the document type before you parse tokens

Label this helper as a minimal reproduction, not a production SDK. Paste it into a Vite or single-file TypeScript shell and break it on purpose. The important line is the early return, not another token buffer.

const SSE_TYPE = "text/event-stream";

export async function readAssistantStream(
  input: RequestInfo,
  init: RequestInit,
  onPhase: (phase: ChatPhase) => void,
): Promise<void> {
  onPhase({ kind: "connecting" });

  const response = await fetch(input, init);

  if (!response.ok) {
    const detail = await response.text();
    onPhase({
      kind: "failedHttp",
      status: response.status,
      detail: detail.slice(0, 180),
    });
    return;
  }

  const mime = (response.headers.get("content-type") ?? "")
    .split(";")[0]
    .trim()
    .toLowerCase();

  if (mime !== SSE_TYPE) {
    onPhase({
      kind: "failedNonSse",
      detail: `Expected ${SSE_TYPE}, received ${mime || "an empty content type"}.`,
    });
    return;
  }

  if (!response.body) {
    onPhase({
      kind: "failedNonSse",
      detail: "The response had no readable body.",
    });
    return;
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  onPhase({ kind: "streaming", buffer });

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      onPhase({ kind: "streaming", buffer });
    }
    onPhase({ kind: "complete", buffer });
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      onPhase({ kind: "cancelled" });
      return;
    }
    throw error;
  } finally {
    reader.releaseLock();
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the early return before getReader(). You never attach a reader to HTML, so the stream cannot hang in a polite generating state. You never leave aria-busy waiting for a data: line that cannot exist in a markup document. You also avoid piping markup into a Markdown renderer that would dump headings into the accessible tree.

Here is a labeled example test for the header branch. It does not replace assistive-technology checks, and you should still walk the focus path by hand.

import { assertEquals } from "https://deno.land/std/assert/mod.ts";
import { readAssistantStream, type ChatPhase } from "./readAssistantStream.ts";

Deno.test("html content type becomes failedNonSse", async () => {
  const originalFetch = globalThis.fetch;
  globalThis.fetch = async () =>
    new Response("<!doctype html><title>Down</title>", {
      status: 200,
      headers: { "content-type": "text/html; charset=utf-8" },
    });

  const phases: ChatPhase["kind"][] = [];
  try {
    await readAssistantStream("/mock", {}, (phase) => {
      phases.push(phase.kind);
    });
  } finally {
    globalThis.fetch = originalFetch;
  }

  assertEquals(phases, ["connecting", "failedNonSse"]);
});
Enter fullscreen mode Exit fullscreen mode

Map phases onto the chat document

Semantic structure matters more than a second live region stacked on the log. Keep one conversation log, one status, and one composer, then let the phase machine drive them. The labeled React example below is unexecuted scaffolding, not a screenshot of a shipped product.

function ChatShell({ phase, onSubmit, onCancel }: Props) {
  const composerRef = useRef<HTMLTextAreaElement>(null);
  const abortable = isAbortable(phase);

  useEffect(() => {
    if (!abortable) composerRef.current?.focus();
  }, [phase.kind, abortable]);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit();
      }}
    >
      <div role="log" aria-busy={abortable} aria-label="Conversation">
        {/* settled turns only */}
      </div>

      <p role="status" aria-live="polite">
        {statusFor(phase)}
      </p>

      <label htmlFor="prompt">Message</label>
      <textarea
        id="prompt"
        ref={composerRef}
        aria-disabled={abortable || undefined}
        readOnly={abortable}
      />

      {abortable ? (
        <button type="button" onClick={onCancel}>
          Stop generating
        </button>
      ) : (
        <button type="submit">Send</button>
      )}
    </form>
  );
}

function statusFor(phase: ChatPhase): string {
  switch (phase.kind) {
    case "connecting":
      return "Connecting to the model.";
    case "streaming":
      return "Generating a response.";
    case "failedNonSse":
      return `The server returned a page instead of a stream. ${phase.detail}`;
    case "failedHttp":
      return `The request failed with status ${phase.status}.`;
    case "cancelled":
      return "Generation cancelled.";
    case "complete":
      return "Response complete.";
    default:
      return "";
  }
}
Enter fullscreen mode Exit fullscreen mode

I use readOnly plus aria-disabled instead of native disabled so the composer remains in tab order while the failure is announced. The effect then returns focus to that named textarea after every non-abortable phase. Native disabled would remove the control from the focus order and hide its accessible name. Do you really want a failed turn to strand keyboard users on a Stop button that can no longer abort anything?

Pointer-independent actions stay on real buttons inside the form. Do not hang click-only recovery on the log, and do not trap Tab inside a decorative overlay. If recovery requires a pointer target with no name, the MIME guard never reached the people who needed it.

A local HTML fixture, then a remote header check

You should not wait for a production outage to test failedNonSse. Serve a fixture and aim the client at it, then watch focus and the status node. The commands below are a local reproduction, not a claim about any hosted quota.

mkdir -p public/fixtures
cat > public/fixtures/error.html <<'EOF'
<!doctype html>
<title>Unavailable</title>
<p>The model proxy is restarting.</p>
EOF

npx --yes serve public -p 4177
Enter fullscreen mode Exit fullscreen mode

Point the client at http://localhost:4177/fixtures/error.html with Accept: text/event-stream. You should land on failedNonSse, hear the status, and find focus back in the composer. If Stop is still focused, the guard never reached the view, and the phase table is only sitting in a comment.

I also pointed the same shell at MonkeyCode while checking remote response headers from a real browser. Disclosure: This article was prepared as part of MonkeyCode's product outreach, not as an independent lab report. MonkeyCode is an open-source project with free model access and a free server option, which gave me a second HTTP target. I still commit the local HTML fixture, and that free server option is enough if you want a remote header check.

Keyboard and screen-reader regressions

Please treat the next matrix as a reproduction invitation, not as a claim that I certified a particular browser. Re-run the connecting to failedNonSse jump with your own browser, OS, and assistive-technology versions. Write those versions next to the exact transition that failed, because a passing unit test will not catch a stuck Stop control.

Transition Keyboard expectation Screen-reader expectation
idleconnecting focus moves to Stop Connecting to the model.
connectingfailedNonSse focus returns to composer error status, aria-busy false
connectingstreaming focus stays on Stop Generating a response.
streamingcomplete focus returns to composer Response complete.
abortable → cancelled focus returns to composer Generation cancelled.

I care about the exact transition that failed in this retrospective: connecting into failedNonSse. If the Stop control remains focused after the HTML body arrives, the view layer ignored the phase. If Generating is still spoken as if tokens might continue, the status mapping is stale. Can a keyboard user recover that turn without reloading the tab?

Limitations, and who should skip this

This guard does not certify WCAG conformance, and it does not replace a real device pass. It does not parse SSE retry fields, binary frames, or chunked tool-call JSON. It also cannot save you if a proxy lies with Content-Type: text/event-stream while the body is still HTML. That lying-header case needs a cautious <!doctype sniff, which remains a heuristic and can false-positive on unusual payloads.

Do not use this approach if your gateway already converts every non-SSE body into a JSON error envelope. Do not use it as cover for skipping keyboard testing, because a green Deno assertion will not move focus. Do not point a production composer at a shared free server and assume isolation or retention rules you have not actually read. Free model access is useful for reproducing header failures, and it is not a privacy boundary.

I would also skip this pattern when the chat is not streaming at all. A unary JSON POST should fail on schema validation, not on MIME theater, and a content-type guard would only add noise. Teams with a locked internal SSE contract and synthetic HTML fixtures may not need a second remote target either.

What I keep in the pull request

The artifact is the phase union, the content-type early return, and focus restoration on every non-abortable phase. Draw the state table first, then break the stream on purpose with HTML. Then ask whether a keyboard user can recover without a mouse and without reloading the tab.

If the composer still cannot speak its own name after a finished error page, you do not have a stream bug. You have a stuck generating state that stole the conversation. Fix the document type before you spend another afternoon polishing the spinner.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Top comments (0)