DEV Community

babycat
babycat

Posted on

The Stop Button Worked Once: Debugging a Reused AbortController in a Streaming Chat

Last week a teammate sent me a two-line message at the end of the day: "Stop works the first time. The second time, the chat just keeps typing and I can't make it stop." She had been testing the retry path after a 500 from our own gateway, and by the time I opened the tab, the transcript was still growing with nobody at the keyboard. Nothing in the console looked like an exception, which is usually the sign that the bug lives in the state machine rather than the network.

Here is the symptom table I wrote before touching any code, because the shape of the failure matters more than the eventual stack trace. Notice that every row is a second interaction: first interactions were all clean, and that is exactly what let the bug survive three review passes.

User action Expected Actual
Send, then Stop stream ends, status becomes Stopped, composer re-enabled correct, first attempt only
Send again, then Stop stream ends again tokens keep arriving, Stop looks inert
Retry after HTTP 500 one new attempt, one status change UI stuck on Generating…, Retry gone
Screen reader user, same path "Generating" then "Stopped" one announcement, then silence

Three symptoms, one object: the AbortController.

Reproduce it before you theorise

I do not trust a fix I cannot trigger on demand, so step one is always a deterministic reproduction. Mine takes about ninety seconds and needs no network at all.

  1. Stub fetch so it emits five chunks, one every 200 ms, then closes.
  2. Send a prompt, click Stop after chunk two, confirm the status reads Stopped.
  3. Send a second prompt, click Stop after chunk two, and watch the transcript.
  4. Log signal.aborted at the top of the request function, every time.
// repro-harness.ts — run with `npx tsx repro-harness.ts`
let calls = 0;
globalThis.fetch = async (_url, init) => {
  calls += 1;
  console.log(`attempt ${calls}, signal.aborted at start:`, init?.signal?.aborted);

  const body = new ReadableStream({
    async start(controller) {
      for (const chunk of ["one ", "two ", "three ", "four ", "five"]) {
        if (init?.signal?.aborted) {
          return controller.error(new DOMException("Aborted", "AbortError"));
        }
        controller.enqueue(new TextEncoder().encode(chunk));
        await new Promise((r) => setTimeout(r, 200));
      }
      controller.close();
    },
  });
  return new Response(body, { status: 200 });
};
Enter fullscreen mode Exit fullscreen mode

The second row of that log is the entire investigation: attempt 2, signal.aborted at start: true. The stream never had a chance, because it was born cancelled.

The root cause was a useRef with a lifetime problem

The component created one controller when it mounted and reused it for every request, which feels tidy and is wrong. An AbortController is a one-shot object: once you abort it, the signal stays aborted forever, and every future fetch that receives it rejects almost immediately.

// buggy
const controllerRef = useRef(new AbortController());

async function send(prompt: string) {
  setStatus({ kind: "streaming" });
  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify({ prompt }),
      signal: controllerRef.current.signal, // aborted forever after the first Stop
    });
    await readStream(res);
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") return; // silent
    setStatus({ kind: "failed", message: "Something broke." });
  }
}
Enter fullscreen mode Exit fullscreen mode

Two bugs compounded into one confusing experience. The cancelled attempt returned early and left status at streaming, so the UI kept advertising Generating… and hid the Retry button. The reused signal then made the next attempt abort within milliseconds, which the same early return swallowed again: a loop of silence that looked exactly like a hung network.

Can you see why the first attempt was clean? Fresh controller, fresh signal, and the abort path ran precisely once.

Fix: bind the controller to the attempt, not the component

I stopped thinking in terms of "the request" and started thinking in terms of "an attempt", because that is the unit a user can cancel, retry, or duplicate. Each attempt carries its own controller, its own id, and its own terminal state.

type Status =
  | { kind: "idle" }
  | { kind: "streaming"; attempt: number }
  | { kind: "cancelled"; attempt: number }
  | { kind: "failed"; attempt: number; message: string; retryable: boolean };

let nextAttempt = 0;
let active: { id: number; controller: AbortController } | null = null;

async function run(prompt: string) {
  active?.controller.abort(); // never leave two attempts alive
  const attempt = { id: ++nextAttempt, controller: new AbortController() };
  active = attempt;
  setStatus({ kind: "streaming", attempt: attempt.id });

  try {
    if (attempt.controller.signal.aborted) throw new Error("attempt started aborted");

    const res = await fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify({ prompt }),
      signal: attempt.controller.signal,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    for await (const chunk of readChunks(res)) {
      if (attempt.id !== active?.id) return; // a superseded attempt writes nothing
      append(chunk);
    }
    setStatus({ kind: "idle" });
  } catch (err) {
    if (attempt.controller.signal.aborted) {
      setStatus({ kind: "cancelled", attempt: attempt.id }); // a real state, announced
      return;
    }
    setStatus({
      kind: "failed",
      attempt: attempt.id,
      message: "Connection dropped.",
      retryable: true,
    });
  }
}

function stop() {
  active?.controller.abort();
  if (active) setStatus({ kind: "cancelled", attempt: active.id });
}
Enter fullscreen mode Exit fullscreen mode

The important change is not the extra types; it is that cancellation became a state instead of a missing return. My previous code treated "the user stopped it" as an absence of information, which is why nothing was announced and nothing re-enabled.

What the assistive technology actually heard

Fixing the controller did not fix the announcement, and this is the part where I usually lose an afternoon. During the stuck state the log region carried aria-busy="true" and never cleared, so polite updates inside it were deferred in my Chrome plus NVDA pass. I now treat a busy flag that outlives its attempt as a bug in its own right, not as cosmetic debt.

  • Keep exactly one role="status" region with aria-live="polite", outside the scrolling transcript.
  • Announce transitions rather than tokens: "Generating", "Stopped", "Retry available".
  • Do not move focus on failure; return focus only when the user explicitly asked for it.
  • Keep Stop focusable while streaming, and make Retry the next tab stop after the error text.
  • Reference the error text from Retry with aria-describedby, so the button is never an unlabelled mystery.

My test plan, and the matrix I run it in

Scenario Input Expected outcome
Cancel then send Stop, then new prompt second stream starts, Stop works again
Cancel twice quickly double-click Stop one announcement, no error flash
Retry after 500 stub 500, click Retry exactly one new request, Retry disabled while pending
Superseded stream Send while streaming old attempt writes nothing to the transcript
Keyboard only Tab to Stop, press Enter focus stays put, status updates

Environments I actually run: Chrome with NVDA on Windows 11, Safari with VoiceOver on macOS, Firefox with Orca on Linux, and iOS Safari with VoiceOver. Firefox deserves more attention than it gets here, because older code paths sometimes surface cancellation as an Error rather than a DOMException.

Where MonkeyCode genuinely fit into this loop

Two things about this bug needed an environment I do not keep lying around: a deployed origin, because proxy buffering changes how chunks arrive, and a large number of failed attempts, because I ran the cancel-and-retry loop dozens of times per session. I used MonkeyCode's free model access to drive those runs without rationing tokens, and its free server option to host the same page on a second origin and compare stream timing against localhost. The operator currently advertises a ten-million-token free allowance plus a free server option; check the project page for the current terms before planning around them, because free-tier details move. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

It is a reasonable way to get a cheap second origin for this exact class of test. It is not a replacement for your own logging, and no hosted tier will tell you that your signal was born aborted.

Limitations and who should skip this

  • If your interface has no cancel affordance, an attempt id is overhead; one controller per mount is genuinely fine.
  • If your framework already scopes mutation cancellation per call, copy the guard pattern, not the plumbing.
  • This pattern does not fix duplicated user messages; that needs an idempotency key on the request instead.
  • Free allowances change over time, so never let a reproduction plan depend on a permanent quota.

What I check first next time

  1. Log signal.aborted at request start and treat true as a hard error.
  2. Make cancellation a rendered state, never an early return.
  3. Assert one live attempt with a module-level id, and bail out of stale writers.
  4. Confirm the aria-busy flag clears on every terminal transition.
  5. Re-run the cancel-retry loop on a second origin before calling anything fixed.

A stop button that works exactly once is never a browser quirk; it is a lifetime bug wearing a network costume. If you hit the same silence, send me the browser, OS, and assistive-technology versions plus the exact interaction that broke, and I will add the row to the matrix.

Top comments (0)