DEV Community

babycat
babycat

Posted on

Stream Cancel Needs a Dead Reader, Not a Hidden Spinner

Last Tuesday I cancelled a streaming agent reply with the keyboard, and the UI lied to me. The Cancel button vanished, the composer unlocked, and I started typing the next prompt like a person who trusts buttons. Two seconds later a leftover sentence dropped under the previous turn, VoiceOver stayed silent, and my caret was gone. Have you ever watched a supposedly stopped chat keep talking after you already moved on?

I was not debugging a production outage, and I will not pretend this was a customer incident with dashboards. This was a local demo against a remote streaming endpoint, the setup frontend teams use before an API freeze. The failure still taught a reusable sequence that I now run on every cancel control I ship. I start with the symptom, then the timeline, then the reader, then focus, then the announcement.

The failure looked like a styling bug

The visible spinner disappeared the instant I pressed Escape, so I assumed the stream was dead. The transcript still received one more markdown fragment, and the new prompt I had typed jumped into the previous assistant bubble. Keyboard focus landed on the document body, which meant the next Tab started at the banner instead of the composer. Why did a cancel handler that called abort() still feel haunted?

Here is the exact transition that failed, written down before I touched CSS again:

  • From: streaming, Cancel focused, aria-busy="true" on the transcript
  • User action: Escape, which should abort the in-flight assistant response
  • Expected: cancelled, composer focused, one announcement, no further tokens
  • Actual: idle paint, then a late token commit, then focus on <body>

I keep that four-line card next to the component now, because cancel bugs hide in the seams between paint and I/O. If you only screenshot the spinner, you will “fix” opacity and ship the race. Does your cancel test even assert that no DOM mutation happens after abort?

Draw the state table before the spinner

Cancel is not a boolean, and treating it like isLoading is how the lie gets into the DOM. I now keep an explicit session status and a monotonic generationId so late chunks can be ignored without debating the network. The table below is the whole product contract for this control.

Status Composer Cancel control Live region Tokens allowed
idle enabled, focused after recover hidden silent no
streaming disabled visible and in tab order “Reply streaming” once yes, matching id
cancelling disabled visible, aria-disabled “Cancelling reply” no
cancelled enabled, restored focus hidden “Reply cancelled” no
error enabled, restored focus hidden error text once no

Notice cancelling is a real state, not a CSS class on a dying spinner. If you skip it, the UI jumps to idle while the reader is still pulling bytes. Should a screen reader hear “cancelled” before the reader is actually dead? No, and that timing is the entire bug.

A runnable session object

The reproduction is a typed session, not a pile of setState flags fighting each other. I want one object that the UI and the reader both consult, because split sources of truth recreate the late chunk. The sketch below is a labeled example for the demo, not a published library and not a benchmark.

type ChatStatus =
  | "idle"
  | "streaming"
  | "cancelling"
  | "cancelled"
  | "error";

type ChatSession = {
  generationId: number;
  status: ChatStatus;
  abort: AbortController;
};

function createSession(id: number): ChatSession {
  return {
    generationId: id,
    status: "streaming",
    abort: new AbortController(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The composer submits by creating a new session and incrementing generationId. Cancel never toggles a boolean; it asks the current session to enter cancelling and then kills I/O on purpose. If a chunk arrives with a stale id, the reducer drops it on the floor. That drop is the product, not an edge case. Would you rather filter tokens in the view layer and hope React batches kindly?

What the debugger actually showed

I paused in the reader loop after abort() and watched one more read() resolve with { done: false, value }. Fetch had rejected, sure, but the stream reader still had a queued chunk from the previous tick. My reducer had already set status: "idle" in the click handler, so the chunk committed as if the user never cancelled. Is that a network bug or a UI bug? It is a missing join between the abort signal and the async iterator.

The fetch aborted, the reader did not

Calling controller.abort() is necessary and not sufficient when you already called getReader(). You still need to stop pulling, ignore the current generationId, and prefer reader.cancel() so backpressure actually stops. A local mock often completes in one tick, which hides the race completely. A remote stream makes the gap wide enough to see with only a keyboard.

I needed that slower, less polite remote behavior, so I pointed the demo at MonkeyCode's free model access on the free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The late chunk still arrived after abort, which is the race a remote endpoint will give you without extra staging hardware. If you need the same gap in a laptop demo, that free model access and free server option are enough to reproduce it.

async function readStream(
  session: ChatSession,
  stream: ReadableStream<Uint8Array>,
  onToken: (id: number, text: string) => void,
) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();

  try {
    while (true) {
      if (session.abort.signal.aborted) {
        session.status = "cancelled";
        await reader.cancel("user-cancel");
        break;
      }

      const { done, value } = await reader.read();
      if (done) break;
      if (session.abort.signal.aborted) continue;
      if (session.status !== "streaming") continue;

      onToken(
        session.generationId,
        decoder.decode(value, { stream: true }),
      );
    }
  } catch (error) {
    if (session.abort.signal.aborted) {
      session.status = "cancelled";
      return;
    }
    session.status = "error";
    throw error;
  }
}

function cancelSession(session: ChatSession) {
  if (session.status !== "streaming") return;
  session.status = "cancelling";
  session.abort.abort("user-cancel");
}
Enter fullscreen mode Exit fullscreen mode

Check the signal before read() and after read(), because the await is the window where the user hits Escape. Swallowing the abort exception without setting cancelled will strand you in cancelling forever. Have you logged session.status on every token, or only on the click?

The composer remount ate focus

My cancel path replaced the whole transcript list with a new array identity, and the composer lived inside that tree. React unmounted the textarea, so focus fell to the body, which is how a keyboard user gets dumped into the header. The fix was a stable composer mounted outside the streaming list, plus explicit textarea.focus() after status landed on cancelled or error. Pointer users never noticed, which is why this class of bug survives an otherwise careful review.

Do not steal focus to Cancel on every token either, because that traps a screen-reader cursor in a button that is about to vanish. I only move focus on user-initiated cancel and on recovery from error. That is a small rule, and it prevents a very loud interface. Would you like your screen reader parked on a control that disappears mid-sentence?

The live region never heard cancelled

I had aria-busy="true" on the log, and I flipped it to false when the spinner hid. Busy state is not an announcement, and a hidden spinner is not a status. The cancelled outcome needs a polite live region with actual text, spoken once, without repeating on every stale chunk you dropped. I also keep the cancelled turn in the transcript as a retired bubble, so history still makes sense when you arrow through it.

<div class="chat">
  <div id="log" role="log" aria-busy="true">
    <article>
      <h2>You</h2>
      <p>Summarize this stack trace without editing it.</p>
    </article>
    <article aria-label="Assistant, cancelled">
      <h2>Assistant</h2>
      <p data-status>Reply cancelled.</p>
    </article>
  </div>
  <p id="live" class="visually-hidden" aria-live="polite">
    Reply cancelled.
  </p>
  <form>
    <label for="prompt">Message</label>
    <textarea id="prompt" name="prompt"></textarea>
    <button type="submit">Send</button>
    <button type="button" id="cancel">Cancel reply</button>
  </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Escape must work from the composer without a mouse, and Cancel must be reachable by Tab while streaming. If Cancel is display: none until hover, you do not have a cancel control. You have a decoration that only pointer users can find.

Wire cancel to both input devices

The handler is the same function for the button and for the composer keydown. I prevent the default Escape behavior only while status === "streaming", so I do not trap people inside the widget after idle. Pointer, keyboard, and assistive-technology activation all call cancelSession, then wait for the reader to finish dying before unlocking the textarea.

function onComposerKeydown(
  event: KeyboardEvent,
  session: ChatSession | null,
) {
  if (event.key !== "Escape") return;
  if (!session || session.status !== "streaming") return;
  event.preventDefault();
  cancelSession(session);
}

function onCancelClick(session: ChatSession | null) {
  if (!session) return;
  cancelSession(session);
}

function announce(live: HTMLElement, text: string) {
  live.textContent = "";
  requestAnimationFrame(() => {
    live.textContent = text;
  });
}

function applyStatus(session: ChatSession, live: HTMLElement, prompt: HTMLTextAreaElement) {
  if (session.status === "cancelled") {
    announce(live, "Reply cancelled");
    prompt.focus();
  }
  if (session.status === "error") {
    announce(live, "Reply failed. You can edit and send again.");
    prompt.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Clearing the live region before setting text avoids some engines that skip duplicate polite strings. I still do not claim this pattern is a conformance badge, because announcement timing differs across browser and screen-reader pairs. Test the transition, not the ARIA attribute list. If the engine speaks “cancelling” and never “cancelled,” your reader is still alive.

The QA card I run before I trust cancel

I reproduce with versions written down at test time, because “VoiceOver on my laptop” is not a report. The failing transition is always the same sentence: streaming, Escape, then one more chunk. Record browser, OS, and assistive-technology versions on the card, then rerun after every reader change.

  1. Chrome stable, macOS, VoiceOver: Escape from the textarea, expect “Reply cancelled”, focus in the textarea, no extra bubble text.
  2. Firefox stable, Windows, NVDA: Tab to Cancel, press Space, expect one announcement and an unlocked composer.
  3. Safari stable, iOS, VoiceOver rotor to form controls: activate Cancel, expect the retired turn, not an empty live region.
  4. Keyboard only, no pointing device: confirm Tab order is log, prompt, Send, then Cancel while streaming.
  5. Point the client at a slow remote stream and cancel mid-sentence; assert a stale generationId drops the next decode.

If step five is green on a mock and red on a remote stream, you do not have flaky CI. You have a reader that still lives. Fix that join before you add retry, because retry on a half-cancelled turn creates a second ghost in the log. Can your test harness tell the difference between a hidden spinner and a dead reader?

Limitations, and who should not copy this

This pattern assumes one in-flight assistant turn and a browser AbortController. It will not save you if the server keeps generating after the client disconnects and later writes into the same thread. It also will not make an inaccessible markdown renderer fine, and it will not replace a backend stop API if aborted connections are ignored.

Do not copy this if you need multi-turn tool approval, because those dialogs need a focus handoff I am not covering here. Do not copy this if your “cancel” is actually undo, or if you stream from a WebSocket you cannot tear down cleanly. Local toy demos that resolve in a single setTimeout will also teach you the wrong lesson, which is how I got here in the first place.

I am not attaching screenshots of a happy path, and I am not declaring the widget accessible in every environment. The artifact is the state table, the dead reader, and the QA card for one transition. If your cancel control cannot survive that card, the spinner was never the problem.

Top comments (0)