Search IOCombats

Search challenges, guides, questions and articles

A user clicks "Stop generating". How do you cancel a streaming fetch with AbortController?

Intermediate10 min interview
Skills tested:
Creating one AbortController per request and passing its signal to fetchKnowing that abort also rejects a pending reader.read() mid-streamTelling an AbortError apart from real errors in catchCancelling in-flight streams on unmount with a useEffect cleanupPropagating cancellation from the browser through a Node server to the LLM provider

Advertisement

🧩 Scenario

Your chat UI streams long answers. Users want a Stop button, and finance has noticed that abandoned answers still show up as output tokens on the bill. The interviewer asks you to design cancellation end to end.

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.

  1. Browser. The fetch and its body reader stop, and the UI keeps what it has.
  2. Your server. It notices the client left and stops forwarding.
  3. 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 useEffect cleanup, so navigating away does not leave a stream running and calling setState on 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

ChoiceBenefitCost
Abort only on the clientSimple, UI stops instantlyProvider may keep generating and billing
Forward req.signal upstreamSaves output tokens, frees server resourcesDepends on the runtime reporting disconnects
Keep partial text on stopUser keeps what they already readNeed a "stopped" state in the message model
Clear text on stopSimpler stateThrows 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 AbortError as 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

  1. How would you add a timeout with AbortSignal.timeout() and still support a manual Stop?
  2. How would you implement "Regenerate" so it reuses the history but not the stopped answer?
  3. How would you verify, in logs, that the provider call actually stopped?
  4. What does reader.cancel() do compared to controller.abort()?
  5. How would you handle Stop if the server streams through a queue or background job instead of directly?

Common Candidate Mistakes

  • Putting the AbortController in useState, 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 setText calls 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 AbortController into 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement