A user clicks "Stop generating". How do you cancel a streaming fetch with AbortController?
Advertisement
🧩 Scenario
Architecture Walkthrough
Three places a stream can be cancelled
Think of the request as a chain: browser → your server → LLM provider. A good Stop button cuts all three links.
- Browser. The fetch and its body reader stop, and the UI keeps what it has.
- Your server. It notices the client left and stops forwarding.
- Provider. Your server aborts its own upstream request, so the model stops generating. Output tokens are billed, so this link is where cost is saved.
How AbortController works in the browser
AbortController is the standard way to cancel async work in JavaScript. You create a controller, hand its signal to fetch, and call controller.abort() when you want to stop. It is like the cleanup function you return from useEffect, but for a network request.
The useful detail for streaming: abort does not only cancel the initial request. If the response is already streaming, the pending reader.read() rejects as well. So one abort() call stops the whole read loop, and your catch block runs.
When you call abort() with no reason, the error is a DOMException with name === 'AbortError'. That is not a failure from the user's point of view, so you should not show an error message. You keep the partial text and mark the message as stopped.
Rules for a real chat UI
- One controller per request. An aborted signal stays aborted forever.
- Abort the previous stream before starting a new one, otherwise two streams can write into the UI at the same time.
- Abort on unmount with a
useEffectcleanup, so navigating away does not leave a stream running and callingsetStateon an unmounted component.
Propagating to the provider
In a Next.js Route Handler, req.signal is the incoming request's AbortSignal. Pass it to the upstream fetch (or to your SDK's cancel mechanism; Anthropic's TypeScript SDK has stream.abort()) so that a client disconnect becomes an upstream abort. Behaviour depends on the runtime: a Vercel community thread reports that req.signal fired under next start but not reliably on Vercel's serverless runtime. Test it on the platform you deploy to.
Key Code Explained
import { useEffect, useRef, useState } from 'react';
export function useStreamingChat() {
const [text, setText] = useState('');
const [status, setStatus] = useState<'idle' | 'streaming' | 'stopped' | 'error'>('idle');
const controllerRef = useRef<AbortController | null>(null);
async function send(prompt: string) {
controllerRef.current?.abort(); // stop any stream still running
const controller = new AbortController(); // fresh controller every time
controllerRef.current = controller;
setText('');
setStatus('streaming');
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read(); // rejects if aborted mid-stream
if (done) break;
setText((prev) => prev + value);
}
setStatus('idle');
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Only mark as stopped if this is still the active request
if (controllerRef.current === controller) setStatus('stopped');
return; // keep the partial text
}
setStatus('error');
}
}
function stop() {
controllerRef.current?.abort();
}
// Abort when the component using the hook unmounts
useEffect(() => () => controllerRef.current?.abort(), []);
return { text, status, send, stop };
}
// app/api/chat/route.ts: forward cancellation to the provider
export async function POST(req: Request) {
const body = await req.text();
const upstream = await fetch(process.env.LLM_STREAM_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body, // in a real app, build the provider request from validated input
signal: req.signal, // client disconnect -> upstream abort (verify on your host)
});
return new Response(upstream.body, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'text/plain' },
});
}
The controllerRef.current === controller check prevents a race: if message B aborted message A, A's catch block should not overwrite B's status. On the server, passing req.signal straight into the upstream fetch is the smallest change that connects the client's Stop button to the provider.
Tradeoffs
| Choice | Benefit | Cost |
|---|---|---|
| Abort only on the client | Simple, UI stops instantly | Provider may keep generating and billing |
| Forward req.signal upstream | Saves output tokens, frees server resources | Depends on the runtime reporting disconnects |
| Keep partial text on stop | User keeps what they already read | Need a "stopped" state in the message model |
| Clear text on stop | Simpler state | Throws away content the user wanted |
What Interviewers Actually Check
- Whether you create a new controller per request
- Whether you know abort rejects a pending
reader.read(), not just the initial fetch - Whether you treat
AbortErroras a normal outcome and keep partial output - Whether you handle rapid re-sends and unmounts without races
- Whether you think about the provider bill, not just the UI
Follow-Up Questions
- How would you add a timeout with
AbortSignal.timeout()and still support a manual Stop? - How would you implement "Regenerate" so it reuses the history but not the stopped answer?
- How would you verify, in logs, that the provider call actually stopped?
- What does
reader.cancel()do compared tocontroller.abort()? - How would you handle Stop if the server streams through a queue or background job instead of directly?
Common Candidate Mistakes
- Putting the
AbortControllerinuseState, causing extra renders and stale controllers - Catching every error the same way, so pressing Stop shows "Something went wrong"
- Forgetting the unmount cleanup, leaving streams running after navigation
- Letting an old stream's
setTextcalls leak into a newer message - Assuming the provider stops automatically when the browser disconnects
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you wire
AbortControllerinto a streaming fetch from memory? - Can you explain what error abort produces and how you detect it?
- Can you keep partial output and show a "stopped" state?
- Can you stop a previous stream when a new message starts?
- Can you explain how cancellation reaches the LLM provider?
Summary
Cancelling a stream means cutting three links: browser, your server and the provider. In the browser, create a new AbortController per request, pass its signal to fetch, and call abort() from Stop, from a new send, or from a useEffect cleanup. The pending read() rejects with an AbortError, which you treat as a deliberate stop while keeping the partial text. On the server, pass req.signal to the upstream request so the model stops generating output you would otherwise pay for, and test that behaviour on your real hosting platform.
Does aborting on the client stop the model on the provider side?
Only if the abort travels all the way. The browser closes its connection to your server, but your server must notice that and abort its own upstream request. If it does not, the model can keep generating, and output tokens are still billed.
Can I reuse an AbortController for the next request?
No. Once a signal is aborted it stays aborted. Create a new AbortController for every request.
How do I tell a user stop apart from a real network error?
When you call abort() with no argument, the rejection is a DOMException named AbortError. Check err.name === "AbortError" (or signal.aborted) and treat it as a deliberate stop. Anything else is a real failure that deserves an error state.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement