Back to News & Insights
JavaScript September 8, 2026 · 8 min read

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...

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 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

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?

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.

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.

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?

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.

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.

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?

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?

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.

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation