{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-07-16-cancel-llm-stream-react-abortcontroller/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"ba2c0916-a919-5097-aa00-3813a628ae9a","excerpt":"A user clicks a different conversation, hits the stop button halfway through a long answer, or just navigates away. The LLM stream you started is still open and…","html":"<p>A user clicks a different conversation, hits the stop button halfway through a long answer, or just navigates away. The LLM stream you started is still open and still appending tokens, and on a metered model it is still spending money. The naive React <code class=\"language-text\">fetch</code> has no idea any of that happened.</p>\n<p>This post is for frontend engineers building a chat UI on top of a streaming endpoint. The last post covered how to <a href=\"/blog/2026-07-13-streaming-markdown-react-llm/\">render streaming Markdown without flicker</a> as tokens land in the browser; this one is about stopping. I ran into every version of the problem building the split-panel chat in <a href=\"/project/cloud-canvas-ai/\">CloudCanvasAI</a>, where switching documents mid-generation used to leave a ghost stream writing into a panel nobody was looking at. I hit it again in the <a href=\"/project/archi/\">Archi</a> copilot, where an operator would fire a question, realise it was wrong, and ask another one before the first finished.</p>\n<p>The browser’s tool for this is <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/AbortController\"><code class=\"language-text\">AbortController</code></a>, an object that lets you cancel a <code class=\"language-text\">fetch</code> after it has started. But “call <code class=\"language-text\">.abort()</code>” is only about a third of the real answer. The other two thirds are where people leak: cleaning up when the component unmounts, and stopping the <em>server</em> from generating, not just the browser from listening.</p>\n<h2>Three moments when a stream must stop</h2>\n<p>A chat stream needs to be cancellable at three points, and they are easy to conflate:</p>\n<ul>\n<li><strong>Stop button.</strong> The user is done with this answer. Kill the current request, keep the conversation.</li>\n<li><strong>Switch context.</strong> The user clicks another chat, or another document. The old request is now irrelevant, and worse, its tokens must not land in the new view.</li>\n<li><strong>Unmount.</strong> The component goes away: route change, tab close, logout. Anything still holding a reference to that stream is now a leak.</li>\n</ul>\n<p>The stop button is the obvious one, and the one most tutorials cover. The other two are where the bugs live, because they fail quietly. Nobody files a report that says “a chat I closed kept costing money.”</p>\n<h2>The version that leaks</h2>\n<p>Here is the shape almost everyone writes first: a <code class=\"language-text\">fetch</code> to a streaming endpoint (the <a href=\"/blog/2026-06-30-fastapi-sse-streaming-llm/\">FastAPI SSE post</a> covers the server side), then a loop that reads chunks from the response’s <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams\">readable stream</a>:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">streamChat</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">prompt<span class=\"token punctuation\">,</span> onToken</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> res <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/api/chat'</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span>\n    method<span class=\"token operator\">:</span> <span class=\"token string\">'POST'</span><span class=\"token punctuation\">,</span>\n    headers<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> <span class=\"token string\">'Content-Type'</span><span class=\"token operator\">:</span> <span class=\"token string\">'application/json'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    body<span class=\"token operator\">:</span> <span class=\"token constant\">JSON</span><span class=\"token punctuation\">.</span><span class=\"token function\">stringify</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> prompt <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> reader <span class=\"token operator\">=</span> res<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span><span class=\"token function\">getReader</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> decoder <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">TextDecoder</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token comment\">// Nothing here can be interrupted from the outside.</span>\n  <span class=\"token keyword\">while</span> <span class=\"token punctuation\">(</span><span class=\"token boolean\">true</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> value<span class=\"token punctuation\">,</span> done <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> reader<span class=\"token punctuation\">.</span><span class=\"token function\">read</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>done<span class=\"token punctuation\">)</span> <span class=\"token keyword\">break</span><span class=\"token punctuation\">;</span>\n    <span class=\"token function\">onToken</span><span class=\"token punctuation\">(</span>decoder<span class=\"token punctuation\">.</span><span class=\"token function\">decode</span><span class=\"token punctuation\">(</span>value<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span> stream<span class=\"token operator\">:</span> <span class=\"token boolean\">true</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>This works in the demo, but it has no off switch. Once the <code class=\"language-text\">while</code> loop starts, only the server closing the stream can end it. There is no handle to hang a cancel on, so the stop button has nothing to call. When the component unmounts, the loop keeps running against a callback that now writes into unmounted state. React will warn about that in development. In production it just quietly does the wrong thing.</p>\n<h2>Giving the stream an off switch</h2>\n<p><code class=\"language-text\">AbortController</code> gives you that handle. You create one, pass its <code class=\"language-text\">signal</code> to <code class=\"language-text\">fetch</code>, and call <code class=\"language-text\">abort()</code> later. That makes both the in-flight request and the pending <code class=\"language-text\">reader.read()</code> reject with a <code class=\"language-text\">DOMException</code> named <code class=\"language-text\">AbortError</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">streamChat</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">prompt<span class=\"token punctuation\">,</span> onToken<span class=\"token punctuation\">,</span> signal</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> res <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/api/chat'</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span>\n    method<span class=\"token operator\">:</span> <span class=\"token string\">'POST'</span><span class=\"token punctuation\">,</span>\n    headers<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> <span class=\"token string\">'Content-Type'</span><span class=\"token operator\">:</span> <span class=\"token string\">'application/json'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n    body<span class=\"token operator\">:</span> <span class=\"token constant\">JSON</span><span class=\"token punctuation\">.</span><span class=\"token function\">stringify</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">{</span> prompt <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    signal<span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> reader <span class=\"token operator\">=</span> res<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">.</span><span class=\"token function\">getReader</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> decoder <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">TextDecoder</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">while</span> <span class=\"token punctuation\">(</span><span class=\"token boolean\">true</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> value<span class=\"token punctuation\">,</span> done <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> reader<span class=\"token punctuation\">.</span><span class=\"token function\">read</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>done<span class=\"token punctuation\">)</span> <span class=\"token keyword\">break</span><span class=\"token punctuation\">;</span>\n      <span class=\"token function\">onToken</span><span class=\"token punctuation\">(</span>decoder<span class=\"token punctuation\">.</span><span class=\"token function\">decode</span><span class=\"token punctuation\">(</span>value<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span> stream<span class=\"token operator\">:</span> <span class=\"token boolean\">true</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>err<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>err<span class=\"token punctuation\">.</span>name <span class=\"token operator\">===</span> <span class=\"token string\">'AbortError'</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">return</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// expected, not a failure</span>\n    <span class=\"token keyword\">throw</span> err<span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Two details trip people up:</p>\n<ol>\n<li><strong>The abort surfaces as a thrown <code class=\"language-text\">AbortError</code>, and that is <em>not</em> a bug.</strong> It is the success path for a cancel. If you let it bubble into your generic error handler, you will flash a red “something went wrong” toast every time someone hits stop. Catch it by name and swallow it, as the <code class=\"language-text\">catch</code> block above does.</li>\n<li><strong>Passing <code class=\"language-text\">signal</code> to <code class=\"language-text\">fetch</code> is what tears down the network connection.</strong> Just stopping the loop is not enough. That distinction is the whole back half of this post.</li>\n</ol>\n<h2>The React lifecycle: one controller per request</h2>\n<p>In a component, the controller has to outlive the function that started the stream, and both the stop button and the cleanup code need to reach it. A ref holds it:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> useRef<span class=\"token punctuation\">,</span> useState<span class=\"token punctuation\">,</span> useEffect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">function</span> <span class=\"token function\">useChatStream</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> <span class=\"token punctuation\">[</span>text<span class=\"token punctuation\">,</span> setText<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token function\">useState</span><span class=\"token punctuation\">(</span><span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> <span class=\"token punctuation\">[</span>streaming<span class=\"token punctuation\">,</span> setStreaming<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token function\">useState</span><span class=\"token punctuation\">(</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> controllerRef <span class=\"token operator\">=</span> <span class=\"token function\">useRef</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">null</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> <span class=\"token function-variable function\">send</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">prompt</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token comment\">// A new send cancels whatever was still running.</span>\n    controllerRef<span class=\"token punctuation\">.</span>current<span class=\"token operator\">?.</span><span class=\"token function\">abort</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n    <span class=\"token keyword\">const</span> controller <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">AbortController</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    controllerRef<span class=\"token punctuation\">.</span>current <span class=\"token operator\">=</span> controller<span class=\"token punctuation\">;</span>\n    <span class=\"token function\">setText</span><span class=\"token punctuation\">(</span><span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token function\">setStreaming</span><span class=\"token punctuation\">(</span><span class=\"token boolean\">true</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n    <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">await</span> <span class=\"token function\">streamChat</span><span class=\"token punctuation\">(</span>prompt<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">chunk</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n        <span class=\"token function\">setText</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">prev</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> prev <span class=\"token operator\">+</span> chunk<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> controller<span class=\"token punctuation\">.</span>signal<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span> <span class=\"token keyword\">finally</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token comment\">// Only clear if this is still the active controller.</span>\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>controllerRef<span class=\"token punctuation\">.</span>current <span class=\"token operator\">===</span> controller<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n        <span class=\"token function\">setStreaming</span><span class=\"token punctuation\">(</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n        controllerRef<span class=\"token punctuation\">.</span>current <span class=\"token operator\">=</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\n      <span class=\"token punctuation\">}</span>\n    <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">const</span> <span class=\"token function-variable function\">stop</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> controllerRef<span class=\"token punctuation\">.</span>current<span class=\"token operator\">?.</span><span class=\"token function\">abort</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token comment\">// Unmount: kill any live stream.</span>\n  <span class=\"token function\">useEffect</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> controllerRef<span class=\"token punctuation\">.</span>current<span class=\"token operator\">?.</span><span class=\"token function\">abort</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span> text<span class=\"token punctuation\">,</span> streaming<span class=\"token punctuation\">,</span> send<span class=\"token punctuation\">,</span> stop <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>With the controller in a ref, each cancel point becomes a line or two:</p>\n<ul>\n<li><strong>Stop:</strong> the <code class=\"language-text\">stop</code> function just aborts the current controller.</li>\n<li><strong>Unmount:</strong> one <code class=\"language-text\">useEffect</code> with an empty dependency array, whose teardown aborts.</li>\n<li><strong>A new prompt:</strong> <code class=\"language-text\">send</code> aborts the previous controller before creating a new one. A fast user firing two prompts in a row does not end up with two live streams writing into the same <code class=\"language-text\">text</code>.</li>\n</ul>\n<p>The <code class=\"language-text\">controllerRef.current === controller</code> check in the <code class=\"language-text\">finally</code> block is not paranoia. By the time an aborted stream unwinds, <code class=\"language-text\">send</code> may already have run again and installed a newer controller. Without the guard, the old request’s <code class=\"language-text\">finally</code> would flip <code class=\"language-text\">streaming</code> back to <code class=\"language-text\">false</code> and null out the ref belonging to the <em>new</em>, still-running request. It is the same class of stale-closure bug (a callback acting on values captured from an earlier run) that the <a href=\"https://react.dev/reference/react/hooks#effect-hooks\">React docs call out for effects that fetch</a>, just moved into an async callback.</p>\n<p><img src=\"/a80dd891a997e9dd2910bf9c001114f8/cancel-lifecycle.svg\" alt=\"The cancellation path from a user action to stopped generation. A stop, chat switch, or unmount calls abort() on the controller; the signal rejects the pending read with AbortError and closes the TCP connection; the ASGI server sees the disconnect and cancels the response task. The dashed branch shows the leak: if you only stop reading in JavaScript but never abort, the connection stays open and the server keeps generating and billing.\"></p>\n<h2>The stale-token race nobody tests for</h2>\n<p>Aborting stops the old stream from <em>continuing</em>. It does not stop a chunk that was already in flight when you aborted from landing in your state.</p>\n<p>Picture the switch-context case. The user is on chat A, a token arrives, and you call <code class=\"language-text\">setText</code>. Then they click chat B, so you abort A and start B. There is a window where A’s last decoded chunk is sitting in a microtask, about to call the <code class=\"language-text\">setText</code> you handed it. That <code class=\"language-text\">setText</code> is closed over chat A’s state, but the screen is showing chat B.</p>\n<p>Abort alone will not save you here, because the chunk was read before the signal fired. You need a second guard: tag each request, and check the tag before you write. A monotonic id (a counter that only goes up) is enough:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token keyword\">const</span> activeId <span class=\"token operator\">=</span> <span class=\"token function\">useRef</span><span class=\"token punctuation\">(</span><span class=\"token number\">0</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> <span class=\"token function-variable function\">send</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">prompt</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n  controllerRef<span class=\"token punctuation\">.</span>current<span class=\"token operator\">?.</span><span class=\"token function\">abort</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> id <span class=\"token operator\">=</span> <span class=\"token operator\">++</span>activeId<span class=\"token punctuation\">.</span>current<span class=\"token punctuation\">;</span>      <span class=\"token comment\">// this send owns id</span>\n  <span class=\"token keyword\">const</span> controller <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">AbortController</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  controllerRef<span class=\"token punctuation\">.</span>current <span class=\"token operator\">=</span> controller<span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">await</span> <span class=\"token function\">streamChat</span><span class=\"token punctuation\">(</span>prompt<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span><span class=\"token parameter\">chunk</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>id <span class=\"token operator\">!==</span> activeId<span class=\"token punctuation\">.</span>current<span class=\"token punctuation\">)</span> <span class=\"token keyword\">return</span><span class=\"token punctuation\">;</span>   <span class=\"token comment\">// a newer send took over; drop it</span>\n    <span class=\"token function\">setText</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token parameter\">prev</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> prev <span class=\"token operator\">+</span> chunk<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> controller<span class=\"token punctuation\">.</span>signal<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Every <code class=\"language-text\">send</code> bumps <code class=\"language-text\">activeId</code>. The token callback checks that its captured <code class=\"language-text\">id</code> is still the active one before it touches state. A late chunk from an abandoned request sees a mismatch and gets dropped on the floor. This is the <a href=\"https://react.dev/reference/react/useEffect#fetching-data-with-effects\">ignore-flag pattern from the React data-fetching docs</a>, applied per chunk instead of once per response, because a stream has many arrival points rather than one.</p>\n<h2>Does the server actually stop?</h2>\n<p>This part surprised me the first time I watched the billing. On the browser side everything looked cancelled: the loop ended, the UI froze the answer, and the error toast stayed away. But the token meter kept ticking for another few seconds. The frontend had stopped <em>listening</em>. The backend had not stopped <em>talking</em>.</p>\n<p>Aborting a <code class=\"language-text\">fetch</code> closes the underlying connection. Whether that stops generation depends entirely on your server noticing and reacting. In FastAPI/Starlette, a client disconnect eventually cancels the task running your streaming generator, and you can also check for it explicitly with <a href=\"https://www.starlette.io/requests/#other-state\"><code class=\"language-text\">await request.is_disconnected()</code></a>. But if that generator is itself <code class=\"language-text\">await</code>ing an upstream model API, closing the browser connection does nothing to that upstream call unless you propagate the cancellation. In this version, the generator checks for a disconnect before it yields each token:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">from</span> fastapi <span class=\"token keyword\">import</span> Request\n\n<span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/api/chat\"</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">chat</span><span class=\"token punctuation\">(</span>request<span class=\"token punctuation\">:</span> Request<span class=\"token punctuation\">,</span> body<span class=\"token punctuation\">:</span> ChatIn<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">gen</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">async</span> <span class=\"token keyword\">for</span> token <span class=\"token keyword\">in</span> model<span class=\"token punctuation\">.</span>stream<span class=\"token punctuation\">(</span>body<span class=\"token punctuation\">.</span>prompt<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n            <span class=\"token keyword\">if</span> <span class=\"token keyword\">await</span> request<span class=\"token punctuation\">.</span>is_disconnected<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n                <span class=\"token keyword\">break</span>            <span class=\"token comment\"># stop pulling from the model, close the upstream call</span>\n            <span class=\"token keyword\">yield</span> token\n    <span class=\"token keyword\">return</span> StreamingResponse<span class=\"token punctuation\">(</span>gen<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> media_type<span class=\"token operator\">=</span><span class=\"token string\">\"text/event-stream\"</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>The <code class=\"language-text\">is_disconnected</code> check is what turns a browser abort into a real stop. Without it, the generator keeps pulling tokens from the model and discarding them into a closed socket, which is the worst of both worlds: you pay for tokens nobody reads. This is the same event-loop cooperation I wrote about in <a href=\"/blog/2026-07-10-fastapi-blocking-event-loop/\">why one blocking call stalls a FastAPI stream</a>: cancellation only works if every <code class=\"language-text\">await</code> in the chain agrees to be cancelled.</p>\n<p>If you stream through a provider SDK, check whether its streaming call honors your cancellation. Most of the Python async clients tie into <code class=\"language-text\">asyncio</code> task cancellation, so breaking out of the <code class=\"language-text\">async for</code> and letting the context manager close is usually enough to end the upstream request. Verify it against your bill, not against the docs.</p>\n<h2>If you use EventSource instead of fetch</h2>\n<p>If your client uses the browser’s <a href=\"https://developer.mozilla.org/en-US/docs/Web/API/EventSource\"><code class=\"language-text\">EventSource</code></a> API instead of <code class=\"language-text\">fetch</code>, none of the <code class=\"language-text\">AbortController</code> code applies. <code class=\"language-text\">EventSource</code> has no signal. You cancel it by calling <code class=\"language-text\">source.close()</code>, which closes the connection the same way an abort does.</p>\n<p>The catch is that <code class=\"language-text\">EventSource</code> can only issue <code class=\"language-text\">GET</code> requests and cannot set an <code class=\"language-text\">Authorization</code> header. That is why chat UIs that need a <code class=\"language-text\">POST</code> body or a bearer token end up on <code class=\"language-text\">fetch</code> plus a manual reader anyway. If you are on <code class=\"language-text\">EventSource</code>, the lifecycle is identical: one instance per request in a ref, and <code class=\"language-text\">close()</code> on stop, switch, and unmount. Only the cancel call changes.</p>\n<h2>Failure modes</h2>\n<p>A few things that bit me, or that I have watched bite other people:</p>\n<ul>\n<li><strong>Treating <code class=\"language-text\">AbortError</code> as an error.</strong> Catch it by name and return quietly. A stop click should not look like a crash.</li>\n<li><strong>StrictMode double-abort.</strong> In development, <a href=\"https://react.dev/reference/react/StrictMode#fixing-bugs-found-by-re-running-effects-in-development\">React runs effects twice</a> to surface cleanup bugs, so an effect that starts a stream and aborts on cleanup will abort immediately on mount. That is React telling you the cleanup works. Drive streams from an event handler like <code class=\"language-text\">send</code>, not from a mount effect, and it is a non-issue.</li>\n<li><strong>Aborting but not guarding.</strong> Abort stops future tokens, not the one already decoded. Keep the request-id check even after you add abort, because the two solve different halves of the problem.</li>\n<li><strong>Frontend-only cancel.</strong> The browser stops reading, but the server keeps generating. Wire the disconnect check on the backend and confirm it against real token usage.</li>\n<li><strong>Reusing a controller.</strong> An <code class=\"language-text\">AbortController</code> is single-use: once aborted, its signal stays aborted forever, so handing the same controller to a second <code class=\"language-text\">fetch</code> starts it already cancelled. Make a new one per request.</li>\n</ul>\n<h2>What I would do differently</h2>\n<p>The first version of this in CloudCanvasAI put everything (fetch, reader loop, abort, state) directly in the component. It worked, and it was unreadable. Now I would start with the version above: a <code class=\"language-text\">useChatStream</code> hook that owns the controller ref and the active id. The component gets <code class=\"language-text\">text</code>, <code class=\"language-text\">streaming</code>, <code class=\"language-text\">send</code>, and <code class=\"language-text\">stop</code>, and never touches an <code class=\"language-text\">AbortController</code> directly. Everything cancellable lives in one place, which is exactly where you want it when the fifth edge case shows up.</p>\n<p>The one thing I would add earlier next time is the server-side disconnect check. Its absence is invisible until you look at a usage graph, and by then you have shipped a UI that cancels beautifully on screen and quietly bills for work it threw away. Cancellation is a two-sided contract, and the browser holds only one side of it.</p>\n<p>If you are building an LLM chat frontend, most of these patterns came from the streaming side of <a href=\"/project/cloud-canvas-ai/\">CloudCanvasAI</a> and <a href=\"/project/archi/\">Archi</a>. The <a href=\"/blog/2026-07-13-streaming-markdown-react-llm/\">render-without-flicker post</a> covers the other half of the same UI.</p>\n<hr>\n<p><em>Diagram by M. Hassan Ahmed, released under CC0 (public domain).</em></p>","frontmatter":{"title":"Cancelling an LLM Stream in React with AbortController","date":"2026-07-16T00:00:00.000Z","description":"A user hits stop or switches chats and the old LLM stream keeps writing tokens and running up cost. How to cancel a streaming fetch in React the right way.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAAB3klEQVQoz1WQaW+jMBCG+bINhw9uY8CYEBKuhABJkzQ0afu9rbrq//8vO7DaSis9HtnjeedSSjlW6fM4fN6PX7fHr2v//jx8vJ2/Xx5/A/fZMw4fYz/xdvl+6t434glUgLKg4YJGQdqU/VgNt3x3WbeX8nhb7k6b7lr1t1VzzurTur1mzbnoRl9Wv3AAEkBBlgR0KgwrMcxENwW1s0G8HtLX0G00E/wS/NhOTTejToptiQArARWIpw/DFMiagAu25DLYr4LOdda6+Tep8FguRB2LuszagOUajcGpGGaMbSgIj/ifjTQa6TQGCxEQBx1iSzA3zXm+FUXoZyqZApS5YKKR8AedRgiykBhm02ho2yJjy1282YuiitarYOU4yZw9UqibkWmShDgSQHaikgBDn7zIo6JPmqPctaJuRTMkTSfqOqp8J9Uw10mowIEdUG+JXUnc1OdlFGyXQS/SLhFN7Jfchz5L7hVVdhrK+6F6iXj1gJhOOIg5tIpw5Nl57G9Dr3LszCDhQmdaItyucNuN0268rmSH2lyvVMQ0GGdGUTHjfiX5nnkFdKviADwINkRj5MUklITLyc4gX+g4RLBRDCmYoiJfx1zF/gNy4a2T4Cexhphq/Ic2l0VWDJEg/APzg0crJbqYnAAAAABJRU5ErkJggg==","aspectRatio":1.899441340782123,"src":"/static/0ae2d35eba94c6cf5e371bebd166c7f2/40a76/hero.png","srcSet":"/static/0ae2d35eba94c6cf5e371bebd166c7f2/c972b/hero.png 340w,\n/static/0ae2d35eba94c6cf5e371bebd166c7f2/27625/hero.png 680w,\n/static/0ae2d35eba94c6cf5e371bebd166c7f2/40a76/hero.png 1360w,\n/static/0ae2d35eba94c6cf5e371bebd166c7f2/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-07-16-cancel-llm-stream-react-abortcontroller/","previous":"blog/2026-07-17-build-mcp-server-llm-agent/","next":"blog/2026-07-15-kubernetes-oomkilled-requests-vs-limits/"}},"staticQueryHashes":["32046230"]}