Your LLM App Is Wasting Money: What Happens When Users Close the Tab? A developer explains how failing to propagate client disconnects in TypeScript LLM servers can waste money, as abandoned generations continue to incur token costs. The post demonstrates using AbortController and AbortSignal to cancel upstream LLM API requests when users close their browser tabs, ensuring cost-efficient streaming. You build an AI chat application. A user sends: "Explain how distributed systems work." Your server calls an LLM API and starts streaming the answer: LLM │ ├── "Distributed" ├── "systems" ├── "are" ├── ... │ ▼ Browser Everything looks great. Then the user closes the browser tab. The response disappears. But what about the LLM request? Is it still running? If your server doesn't explicitly propagate cancellation, the answer may be yes. And that's not just a correctness problem. For an AI application, it can become a cost problem . A user can abandon a generation after 500 tokens, while your backend continues paying for the remaining thousands of tokens. In this article, we'll build the cancellation path for a TypeScript LLM server: Browser │ │ client disconnect ▼ Hono / Node.js │ │ AbortSignal ▼ fetch │ │ cancellation ▼ LLM API Along the way, we'll look at: AbortController and AbortSignal workConsider the simplest possible server: js app.post '/api/chat', async c = { const response = await fetch LLM API URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: Bearer ${API KEY} , }, body: JSON.stringify { model: '...', messages: { role: 'user', content: 'Explain distributed systems', }, , stream: true, } , } ; return new Response response.body, { headers: { 'Content-Type': 'text/event-stream', }, } ; } ; This works. The browser sends a request: Browser │ │ POST /api/chat ▼ Server │ │ fetch ▼ LLM API The LLM starts generating tokens, and the server streams them back to the browser. But now: Browser │ X │ │ tab closed The browser is gone. What happens to the fetch call to the LLM? Nothing automatically tells your application to cancel it. The server and the upstream LLM request are separate operations. Your server needs to explicitly connect their lifecycles. There are actually two HTTP connections here. Connection 1 Browser ────────────────► Server │ │ Connection 2 ▼ LLM API The browser controls Connection 1. Your server controls Connection 2. When the browser closes the tab: Browser Server LLM API │ │ │ │──── HTTP request ─────►│──── HTTP request ───►│ │ │ │ X │ │ │ │ │ │ connection closed │ │ │ │ │ │ │ │──── still connected ─►│ The LLM API doesn't magically know that the browser disappeared. Your server has to propagate the cancellation: Browser X │ ▼ Server │ │ abort ▼ LLM API This is where AbortController comes in. The Web Platform already gives us a standard cancellation mechanism: js const controller = new AbortController ; const response = await fetch url, { signal: controller.signal, } ; // Later: controller.abort ; The important part is: signal: controller.signal The AbortSignal is passed into the operation. When: controller.abort ; is called, APIs that support the signal can terminate the operation. Node.js supports AbortSignal throughout its asynchronous APIs, including streams and HTTP-related operations. This gives us a useful mental model: AbortController │ │ signal ▼ ┌─────────────┐ │ async work │ └─────────────┘ │ │ abort ▼ cancelled The controller doesn't need to know what it's cancelling. It simply broadcasts: "Stop." Any operation that received its signal can react accordingly. Now we need to answer the first question: How does the server know that the browser has disconnected? With Hono, the request exposes the underlying request signal: c.req.raw.signal This signal is aborted when the client connection is terminated. So we can connect it directly to the LLM request: js app.post '/api/chat', async c = { const response = await fetch LLM API URL, { method: 'POST', signal: c.req.raw.signal, headers: { 'Content-Type': 'application/json', Authorization: Bearer ${API KEY} , }, body: JSON.stringify { model: '...', messages: { role: 'user', content: 'Explain distributed systems', }, , stream: true, } , } ; return new Response response.body, { headers: { 'Content-Type': 'text/event-stream', }, } ; } ; Now the lifecycle looks like this: Browser │ │ request ▼ Hono │ │ c.req.raw.signal ▼ fetch │ ▼ LLM API If the browser disconnects: Browser X │ ▼ c.req.raw.signal │ │ aborted ▼ fetch │ │ cancelled ▼ LLM API This is the critical connection that many first versions of AI applications miss. There is another failure mode. What if the LLM API simply takes too long? You don't want a request hanging forever. So we have two independent cancellation conditions: ┌─────────────────┐ │ Client closes │ │ browser │ └────────┬────────┘ │ ▼ CANCEL ▲ │ ┌────────┴────────┐ │ Server timeout │ │ 30 seconds │ └─────────────────┘ We want: Cancel if eithercondition occurs. Modern JavaScript gives us exactly that: AbortSignal.any AbortSignal.any creates a signal that aborts when any of the supplied signals aborts. It is available in Node.js 20+ and later versions. So: js const timeoutSignal = AbortSignal.timeout 30 000 ; const signal = AbortSignal.any timeoutSignal, c.req.raw.signal, ; Now one signal represents both conditions. Putting it together: js app.post '/api/chat', async c = { const signal = AbortSignal.any AbortSignal.timeout 30 000 , c.req.raw.signal, ; try { const response = await fetch LLM API URL, { method: 'POST', signal, headers: { 'Content-Type': 'application/json', Authorization: Bearer ${API KEY} , }, body: JSON.stringify { model: '...', messages: { role: 'user', content: 'Explain distributed systems', }, , stream: true, } , } ; return new Response response.body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', }, } ; } catch error { if signal.aborted { console.log 'LLM request cancelled' ; } throw error; } } ; There are now two ways the request can terminate: ┌── browser closes │ │ AbortSignal.any ──┤ │ │ └── 30s timeout │ ▼ abort │ ▼ fetch │ ▼ LLM API This is much better than manually maintaining separate timers and disconnect handlers. Cancellation becomes particularly important when you're streaming LLM output. Without streaming: Browser ── request ──► Server ──► LLM 20 seconds Browser ◄────────────── complete response With streaming: Browser ◄── token ── token ── token ── token ── ... The request may stay alive for tens of seconds or even minutes. That creates a much larger cancellation window. The architecture is essentially a stream pipeline: LLM API │ │ chunks ▼ SSE parser │ │ events ▼ TransformStream │ │ events ▼ HTTP response │ ▼ Browser The important thing is that the stream is not a special "AI" mechanism. It's just a stream-processing pipeline. Node.js and the Web Streams API provide cancellation mechanisms through AbortSignal , and stream operations can be terminated when their signal is aborted. This is why understanding server-side streams is so useful when building AI applications. There's another subtle problem with streaming. Suppose the LLM sends: data: Hello\n\n data: world\n\n You might imagine that your HTTP client receives exactly those chunks. It doesn't have to. The network might give you: data: Hel then: lo\n\ndata: wor then: ld\n\n A TCP chunk is not necessarily an application-level message. So your streaming pipeline needs to buffer incomplete data: Network chunks │ ▼ ┌──────────────┐ │ Buffer │ └──────┬───────┘ │ ▼ Complete SSE events │ ▼ Application A TransformStream is a natural fit: js function createLineTransform : TransformStream