DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Cancelling an In-Flight Request From the Browser

AbortController is one object with one method. Getting a stop button to actually stop a model is not about that object — it is about the fact that there are three separate connections between the button and the GPU, and aborting the first one does nothing to the third unless you wired it.

AbortController in one minute

A controller owns a signal. Pass the signal to anything that accepts one; call abort() to fire it. Every in-flight operation holding that signal rejects with a DOMException whose name is "AbortError".

const controller = new AbortController();

fetch("/api/chat", { signal: controller.signal })
  .then((res) => res.text())
  .catch((err) => {
    if (err.name === "AbortError") return;  // expected
    throw err;
  });

controller.abort();   // one-way; a controller cannot be reset
Enter fullscreen mode Exit fullscreen mode

Three things about it that are not obvious. A controller is single-use — once aborted it stays aborted, so a new request needs a new controller. AbortSignal.timeout(ms) gives you a signal that fires itself, with no controller to manage. And AbortSignal.any([a, b]) combines signals, which is exactly what you want when a request should stop on either the user’s click or a deadline.

const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(60_000)]);
await fetch("/api/chat", { signal });
Enter fullscreen mode Exit fullscreen mode

AbortSignal.timeout and AbortSignal.any are newer than AbortController itself. Both are available in current browsers and in Node 18+ / 20+ respectively; if you support older runtimes, check your baseline before relying on them, and fall back to a setTimeout that calls abort().

The three hops an abort must cross

A browser talking to a model through your own backend has three connections in series, and each one needs its own reason to stop.

Hop Description
Browser to your server controller.abort() on the client fetch. This closes the TCP connection and stops bytes arriving. It is the only hop most tutorials cover.
Your server to the provider Nothing happens automatically. Your handler must pass request.signal into the upstream fetch. Without that line, your function keeps reading tokens from the provider and discarding them into a socket nobody is listening on.
The provider to the model Out of your hands. Most providers stop generation when the client disconnects, because they are paying for the GPU. Whether tokens already produced are billed is a provider policy question, not a protocol one.
// app/api/chat/route.ts — the whole fix is the last property
export async function POST(request: Request) {
  const upstream = await fetch(PROVIDER_URL, {
    method: "POST",
    headers: HEADERS,
    body: BODY,
    signal: request.signal,     // <- hop two
  });
  return new Response(upstream.body, { headers: SSE_HEADERS });
}
Enter fullscreen mode Exit fullscreen mode

On a serverless platform there is a fourth question hiding behind hop two: does the platform tell your function that the client hung up? Behaviour differs by platform and has changed over time. Treat client disconnect as best-effort, and bound the worst case with max_tokens and a server-side AbortSignal.timeout so that even a disconnect nobody noticed cannot run for minutes.

What you still pay for

Here is the derivation, because the intuition is wrong in a way that costs money. Suppose an answer would be 800 output tokens at 60 tokens per second, and the user hits stop after 3 seconds.

tokens generated before the abort  = 60 tok/s × 3 s = 180
tokens never generated             = 800 − 180        = 620

Input tokens are billed in full: the prompt was processed
during prefill, before the first token existed.

So the saving from cancelling is at most the 620 output tokens,
and only if generation actually stopped upstream.
If it did not stop, the saving is zero — you paid for 800 and
displayed 180.
Enter fullscreen mode Exit fullscreen mode

Two conclusions follow. First, a stop button is primarily a user experience feature: it gives back control and frees the UI. Treating it as a cost control only works if hop two is wired, and you should verify that rather than assume it. Second, the input side is never recoverable, so if cost is the concern the lever is a shorter prompt or a smaller context budget, not a faster finger.

Verify hop two directly: log usage from the provider’s final event or from your gateway’s record, then cancel a long generation early and check whether the recorded output tokens are close to what was displayed or close to the full answer. That is a five-minute experiment and it answers the question for your specific provider, which no article can.

Because cancellation only saves money when it reaches the provider, it is worth being able to see the completion token count for a request you cancelled. Multigrid records usage and cost per request including on aborted streams, which turns “did the stop button do anything” into a number rather than an argument.

Timeouts, supersession and cleanup

Three patterns cover almost every real use, and all three are the same object used differently.

Supersession: the newest request wins

A user editing a prompt and re-submitting should not see two answers interleave. Keep one controller in a ref and abort the previous before starting the next.

const inFlight = useRef<AbortController | null>(null);

async function send(prompt: string) {
  inFlight.current?.abort();                 // cancel the previous
  const controller = new AbortController();
  inFlight.current = controller;

  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify({ prompt }),
      signal: controller.signal,
    });
    // ...read the stream
  } catch (err) {
    if ((err as Error).name !== "AbortError") setError(String(err));
  } finally {
    if (inFlight.current === controller) inFlight.current = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The finally guard matters: without the identity check, a slow aborted request finishing after a new one started would clear the new request’s controller, and the stop button would silently stop working.

Unmount: the effect cleanup

useEffect(() => {
  const controller = new AbortController();
  load(controller.signal);
  return () => controller.abort();
}, []);
Enter fullscreen mode Exit fullscreen mode

In React’s development Strict Mode this effect runs, cleans up and runs again, so you will see one aborted request per mount in development and not in production. That is the intended behaviour and not a bug to work around.

Telling an abort from a failure

An abort arrives as a rejection, which means it lands in the same catch as a network failure and a 500. Reporting it as an error produces the worst kind of noise: an error dashboard full of users behaving exactly as designed.

function isAbort(err: unknown): boolean {
  return err instanceof DOMException && err.name === "AbortError";
}

try {
  await send(prompt);
} catch (err) {
  if (isAbort(err)) return;                    // deliberate, say nothing
  if (err instanceof TypeError) {
    setError("Network unreachable.");          // fetch's failure mode
    return;
  }
  setError("Something went wrong.");
  report(err);
}
Enter fullscreen mode Exit fullscreen mode

Two more distinctions worth building in from the start. A signal from AbortSignal.timeout rejects with name === "TimeoutError", not "AbortError", so a deadline and a user click are already distinguishable without any bookkeeping. And a failed fetch — DNS failure, offline, CORS — rejects with a TypeError and no useful message, which is a browser privacy decision rather than something you can improve; the only honest rendering of it is “could not reach the server”. Everything else about surfacing these is in normalising API errors.

Related

Top comments (0)