DEV Community

babycat
babycat

Posted on

An Empty Model Handshake Needs a Recoverable Error, Not a Disabled Composer

I tabbed to Send, hit Enter, and watched the composer go grey like a locked door. The status pulse kept moving, yet nothing useful was spoken, which is how you know the interface has already decided you do not matter. Was the free server asleep, or did the browser swallow an empty 200 as a finished reply? This walkthrough is a constructed reproduction on a tiny harness, not a production war story with fake metrics.

I had wired one chat surface to two backends so I could watch failures change costume. One path was remote free-model access; the other was a free server I could restart between runs. The pointer path looked almost fine: a spinner, a blank bubble, then I clicked the field and typed again. The keyboard path was a different product, and that gap is the whole article.

The failure, without the happy path

After Enter, focus vanished into a textarea that had become disabled. Tab skipped Retry because Retry was not in the tree until error became truthy. The live region stayed quiet because we only announced token deltas, never handshake outcomes. Does that sound like a flaky network? It presented as one. It was a state-machine bug wearing a network costume, and booleans made it easy to ship.

I ignored my own state table until the composer stopped accepting Tab. Do not do that. Put the table above the CSS, then actually follow it when the first byte never arrives.

Expected UI states

State Composer Primary control Status text Live region Focus owner
idle enabled Send Ready polite composer
handshaking readonly Cancel Connecting polite status or Cancel
streaming readonly Cancel Receiving polite, throttled Cancel or composer
empty enabled Send No content returned assertive composer
timeout enabled Retry Connection never started assertive Retry
http_error enabled Retry Visible status code assertive Retry

The bug lived between handshaking and streaming. An empty, successful body never became empty. A missing done event never became timeout. finally did not save us, because the reader loop ended “cleanly” with zero frames.

Debugging order I now refuse to skip

If you only steal one thing, steal the order. Tools are optional; sequence is not.

  1. Reproduce with keyboard only, then with a screen reader, and never with the pointer first.
  2. Snapshot the accessibility tree the moment Send is pressed, not after you get bored.
  3. Log every phase transition with timestamps, including the ones you are sure are impossible.
  4. Inspect the network row: status, content-type, time to first byte, and whether any data: frame arrived.
  5. Ask whether disabled on the composer or a wrapping fieldset is removing Tab stops.

Keyboard reproduction

I put the mouse out of reach, which is theatrical and strangely effective for frontend work. Tab to the composer, type a short prompt, Tab to Send, press Enter. Composer locks. Tab again. Focus jumps to a skip link because Retry is hidden until we feel ready to admit failure. Why do we hide recovery until the object model is embarrassed? Because we modeled only streaming success.

Turn on a screen reader and repeat the same transition without peeking at the spinner. If the rotor finds an old heading inside a prior assistant bubble, your status node is decorative. Have you ever trusted a pulse animation as if it were an announcement? Sighted teams do this constantly, then call the result “simple.”

Network truth versus UI truth

One run returned 200 with Content-Type: text/event-stream, a comment preamble, and then a close. No data: frame. No event: done. fetch resolved. The read() loop exited. We set streaming = false without assistant text, and left aria-busy="true" because busy meant !text && !error. Empty text plus no error is the lie.

A second run died earlier: the preflight never finished, fetch rejected, and catch stored nothing. The latch stayed true. Two endpoints, two costumes, one boolean that could not name silence. If your mock always emits a token, you will never see this. Break the first byte on purpose.

# Proposed checks against a local harness, not a claimed production capture.
curl -i -N "$ENDPOINT" \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  --data '{"prompt":"ping"}'
Enter fullscreen mode Exit fullscreen mode

Look at three boring facts before you touch ARIA. Did the socket open? Did a frame arrive? Did the UI name that outcome? If you cannot answer from logs, you are decorating a spinner.

Root cause

The composer used a boolean latch instead of a typed phase. Booleans cannot represent “handshake started but no first token.” They also cannot represent “HTTP success with zero events.” Search your tree for this shape before you blame the model.

// Broken: two booleans cannot name an empty handshake.
let streaming = false;
let error: string | null = null;

async function send(prompt: string) {
  streaming = true;
  error = null;
  const res = await fetch(endpoint, {
    method: "POST",
    body: JSON.stringify({ prompt }),
    signal,
  });
  if (!res.ok) throw new Error(String(res.status));
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  // A clean close with no frames still looks like success here.
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    append(decoder.decode(value, { stream: true }));
  }
  streaming = false;
}
Enter fullscreen mode Exit fullscreen mode

When streaming is true, the textarea gets disabled. Disabled form controls leave the tab order in common desktop browsers. Retry renders only when error is truthy. Empty success sets neither flag. Where does focus go after Send? Anywhere except the control the person just used.

The fix: name the handshake

I replaced the booleans with a discriminated union and a timer that owns the first byte, not the full generation. This is not a stall detector for tokens that pause mid-sentence; that clock is a different bug. This timer only answers whether a handshake produced a stream event, an HTTP error, or silence.

type ChatPhase =
  | { kind: "idle" }
  | { kind: "handshaking"; startedAt: number }
  | { kind: "streaming"; receivedBytes: number }
  | { kind: "empty" }
  | { kind: "timeout" }
  | { kind: "error"; message: string; status?: number };

type PhaseEvent =
  | { type: "send" }
  | { type: "first-byte"; bytes: number }
  | { type: "end" }
  | { type: "tick"; now: number }
  | { type: "fail"; message: string; status?: number }
  | { type: "retry" };

const HANDSHAKE_MS = 8000; // demo default, measure your own endpoint

function reduce(phase: ChatPhase, event: PhaseEvent): ChatPhase {
  switch (event.type) {
    case "send":
    case "retry":
      return { kind: "handshaking", startedAt: Date.now() };
    case "first-byte":
      return { kind: "streaming", receivedBytes: event.bytes };
    case "end":
      if (phase.kind === "streaming" && phase.receivedBytes > 0) return { kind: "idle" };
      return { kind: "empty" };
    case "tick":
      if (
        phase.kind === "handshaking" &&
        event.now - phase.startedAt > HANDSHAKE_MS
      ) {
        return { kind: "timeout" };
      }
      return phase;
    case "fail":
      return { kind: "error", message: event.message, status: event.status };
    default:
      return phase;
  }
}
Enter fullscreen mode Exit fullscreen mode

What the DOM must do when the first byte never comes

  • handshaking: composer is readonly, never disabled; Send becomes Cancel; status text exists for assistive tech.
  • empty: assertive “The endpoint returned no content.”; focus returns to the composer; Retry sits next to Send.
  • timeout: abort the fetch you still own; announce that the connection never started; move focus to Retry.
  • http_error: put the status code in visible text, not only in DevTools.

Readonly still participates in tab order. Disabled does not. That attribute choice is the difference between recovery and a spinner that ate your keyboard. Escape should cancel only while handshaking or streaming. After timeout, Escape must not poke a dead AbortController.

<form id="chat">
  <p id="status" role="status" aria-live="polite" aria-atomic="true">Ready.</p>
  <label for="composer">Message</label>
  <textarea id="composer" rows="3"></textarea>
  <div class="actions">
    <button type="submit" id="primary">Send</button>
    <button type="button" id="retry" hidden>Retry last message</button>
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode
function render(phase: ChatPhase) {
  const composer = document.querySelector<HTMLTextAreaElement>("#composer")!;
  const primary = document.querySelector<HTMLButtonElement>("#primary")!;
  const retry = document.querySelector<HTMLButtonElement>("#retry")!;
  const status = document.querySelector("#status")!;

  const busy = phase.kind === "handshaking" || phase.kind === "streaming";
  composer.readOnly = busy;
  composer.removeAttribute("disabled");

  const recoverable =
    phase.kind === "timeout" || phase.kind === "error" || phase.kind === "empty";
  retry.hidden = !recoverable;
  status.setAttribute("aria-live", recoverable ? "assertive" : "polite");

  if (phase.kind === "handshaking") {
    status.textContent = "Connecting to the model endpoint.";
    primary.textContent = "Cancel";
  } else if (phase.kind === "empty") {
    status.textContent = "The endpoint returned no content. You can retry or edit.";
    primary.textContent = "Send";
    composer.focus();
  } else if (phase.kind === "timeout") {
    status.textContent = "The connection never started. Retry is available.";
    retry.focus();
  } else if (phase.kind === "error") {
    status.textContent = phase.status
      ? `Request failed with ${phase.status}. Retry is available.`
      : `Request failed. ${phase.message}`;
    retry.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Have you logged whether Retry starts a second fetch before the first abort settles? Do that once. Duplicate owners produce two bubbles and a live region that cannot decide which failure to read.

Why two real endpoints made the bug honest

Mocks rarely close a stream in the ugly way a quiet gateway does, so I wanted disposable targets I could break on purpose. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the harness at MonkeyCode’s free model access and its free server option, then compared an empty preamble with a connection that never left handshake. The product is not the fix. The phase table is the fix. The endpoints only made silence reproducible without inventing a fake gateway or a quota story.

If you already have a server you can crash, use that and keep the same table. The open-source repo is optional scaffolding, not a requirement for the accessibility behavior.

Proposed QA matrix

Treat this as a checklist for the demo, not a conformance certificate. I am not claiming WCAG, and a blog post should not either.

You record Transition Must observe
Browser, OS, screen reader versions idlehandshakingempty assertive empty message, composer focused, Send enabled
Same, keyboard only idlehandshakingtimeout Retry focused, readonly lifted, Escape unbound
Same, throttled network http_error visible status code, Retry before footer in tab order
Pointer and keyboard double activate Send one request, one abort owner

Dump document.activeElement on every phase change. If it is body after Send, you already lost the plot. Record the exact transition that failed, not a vibe that “chat felt broken.”

Limitations, and who should skip this

This pattern does not diagnose token stalls after the first byte; those need a different clock and a different announcement policy. It does not replace authentication, rate-limit copy, or content filtering. Eight seconds is a demo handshake budget, not a service promise, and you should measure your own endpoints before copying the constant.

Skip this approach if your composer is contenteditable with custom Tab stops you cannot inventory. Skip it if you cannot abort the fetch you started. Skip it if you still dump every token into a live region, because that backlog will bury the empty-success message. Skip the “just disable the form” shortcut, which is how this whole mess presented as a spinner.

What I want you to reproduce

Clone the state table, not the styling. Break the server so it returns an empty stream, then tab through Send without a mouse. If focus dies, your booleans are lying. If nothing is announced, your status node is decoration. If Retry appears only after a hover, you shipped mouse-only recovery and called it shipping.

I still catch myself trusting a pulse that never learned to speak. Do you?

Top comments (0)