n4nAI

Canceling in-flight LLM requests in React with AbortController

Learn how to implement react abortcontroller llm streaming cancel patterns to stop inflight requests cleanly in your chat UI with runnable code.

n4n Team3 min read636 words

Audio narration

Coming soon — every post will get a voice note here.

Wiring up react abortcontroller llm streaming cancel logic is the difference between a chat UI that feels responsive and one that leaks tokens and connections when users hit stop. Most React apps fire a fetch and forget the handle; when the model is mid-stream, you need to tear down the reader and the underlying socket. This guide walks through a complete pattern for canceling in-flight LLM streams from a React component.

Step 1: Own the AbortController in a dedicated hook

Don’t scatter new AbortController() across event handlers. Centralize streaming state in a custom hook so every new send cancels the previous run and exposes a single cancel() function. Store the controller in a ref, not state—state updates trigger re-renders and can capture stale closures.

import { useRef, useState, useCallback } from 'react';

export function useLLMStream() {
  const abortRef = useRef<AbortController | null>(null);
  const [isStreaming, setIsStreaming] = useState(false);
  const [text, setText] = useState('');

  const cancel = useCallback(() => {
    abortRef.current?.abort();
    abortRef.current = null;
    setIsStreaming(false);
  }, []);

  const send = useCallback(async (prompt: string) => {
    cancel(); // tear down any prior stream first
    const controller = new AbortController();
    abortRef.current = controller;
    setIsStreaming(true);
    setText('');

    try {
      const res = await fetch('https://api.example.com/v1/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: 'gpt-4o-mini',
          messages: [{ role: 'user', content: prompt }],
          stream: true,
        }),
        signal: controller.signal,
      });
      if (!res.body) throw new Error('No response body');
      // reader logic added in Step 2
    } catch (err) {
      if ((err as Error).name !== 'AbortError') throw err;
    } finally {
      setIsStreaming(false);
      abortRef.current = null;
    }
  }, [cancel]);

  return { send, cancel, isStreaming, text, setText };
}

The critical line is cancel() at the top of send. That guarantees a rapid second message doesn’t leave two streams fighting over the same state. Without it, a user clicking Send twice will interleave tokens.

Step 2: Parse Server-Sent Events without blocking the abort

OpenAI-compatible endpoints return newline-delimited data: frames. You must read the body stream incrementally and parse lines, not wait for the full response. The AbortController signal propagates to the fetch; when aborted, reader.read() rejects with AbortError.

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const frames = buffer.split('\n\n');
  buffer = frames.pop() ?? '';
  for (const frame of frames) {
    const line = frame.trim();
    if (!line.startsWith('data:')) continue;
    const payload = line.slice(5).trim();
    if (payload === '[DONE]') continue;
    try {
      const json = JSON.parse(payload);
      const delta = json.choices?.[0]?.delta?.content ?? '';
      setText((prev) => prev + delta);
    } catch {
      // ignore malformed keep-alive comments
    }
  }
}

If you route through a gateway such as n4n.ai, the same signal cancels the stream on its single OpenAI-compatible endpoint covering 240+ models, and per-token metering stops at disconnect. The AbortError is expected; swallow it specifically so it doesn’t bubble as an unhandled rejection. Note that some providers send periodic : ping comments—your parser must ignore lines not starting with data:.

Handling reader cleanup explicitly

After an abort, call reader.cancel() in a finally block to release the underlying stream if the loop didn’t exit via done:

} finally {
  try { await reader.cancel(); } catch {}
  setIsStreaming(false);
}

Step 3: Bind cancel to UI and keyboard shortcuts

A stop button is table stakes. Power users expect Escape to abort. Wire both to the hook’s cancel and reflect streaming state in the DOM.

import { useEffect, useState } from 'react';
import { useLLMStream } from './useLLMStream';

export function ChatBox() {
  const { send, cancel, isStreaming, text } = useLLMStream();
  const [input, setInput] = useState('');

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isStreaming) cancel();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [isStreaming, cancel]);

  return (
    <div>
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Ask something…"
      />
      <button onClick={() => send(input)} disabled={isStreaming}>
        Send
      </button>
      <button onClick={cancel} disabled={!isStreaming}>
        Stop
      </button>
      <pre>{text}</pre>
    </div>
  );
}

Disable Send while streaming to prevent overlapping calls. The Escape listener depends on isStreaming so it doesn’t fire when idle. This completes the react abortcontroller llm streaming cancel wiring for the view layer.

Step 4: Clean up on unmount and avoid state updates after abort

React warns if you setText on an unmounted component. Track mounted state and abort on unmount. This also protects against StrictMode double-mount in development, where effects run twice.

const mounted = useRef(true);
useEffect(() => {
  mounted.current = true;
  return () => {
    mounted.current = false;
    abortRef.current?.abort();
  };
}, []);

// inside the reader loop, guard state updates:
if (mounted.current) setText((prev) => prev + delta);

Without the unmount abort, navigating away mid-stream leaves the fetch running and the socket open until the model finishes—wasting bandwidth and potentially throwing later. The mounted ref avoids the “can’t perform state update on unmounted component” error in older React versions.

Race conditions with rapid clicks

If a user clicks Send, then Stop, then Send within milliseconds, the first cancel() in send aborts the first controller, but the second send creates a new one. Because abortRef is overwritten synchronously, the first stream’s finally may run after the second starts. Guard with a local controller variable rather than only the ref:

const controller = new AbortController();
abortRef.current = controller;
// later in finally:
if (abortRef.current === controller) abortRef.current = null;

Step 5: Verify the cancellation works

Run a local mock streamer to observe disconnects. This Python server streams numbered frames and breaks when the client closes the socket.

import asyncio
from aiohttp import web

async def handle(request):
    response = web.StreamResponse()
    await response.prepare(request)
    for i in range(100):
        if request.transport.is_closing():
            break
        await response.write(f"data: {{\"choices\":[{{\"delta\":{{\"content\":\"{i} \"}}}}]}}\n\n".encode())
        await asyncio.sleep(0.1)
    await response.write(b"data: [DONE]\n\n")
    return response

app = web.Application()
app.router.add_post('/v1/chat/completions', handle)
web.run_app(app, port=8080)

Point the hook at http://localhost:8080/v1/chat/completions. Open Chrome DevTools Network tab, click Send, then Stop. The request row turns red with “(canceled)”. The Python loop breaks because request.transport.is_closing() detects the closed socket. That confirms your react abortcontroller llm streaming cancel pipeline is solid.

For production, watch your provider logs or usage dashboard. If you still see tokens generated after client cancel, the signal isn’t reaching the fetch—check that signal: controller.signal is actually passed and not shadowed by a config object. With proper abort forwarding, the metered token count reflects only tokens delivered before disconnect.

Quick unit check

You can simulate a stream in tests with a ReadableStream:

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'));
    controller.close();
  },
});
// attach to a Response and assert setText receives "hi" before abort

This catches parser regressions without a live model.

Following these steps gives you a chat UI where Stop is instantaneous, tokens stop flowing, and no orphaned connections linger in the background.

Tagsreactabortcontrollerstreamingllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All react streaming chat ui patterns posts →