{"slug": "your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab", "title": "Your LLM App Is Wasting Money: What Happens When Users Close the Tab?", "summary": "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.", "body_md": "You build an AI chat application.\n\nA user sends:\n\n\"Explain how distributed systems work.\"\n\nYour server calls an LLM API and starts streaming the answer:\n\n```\nLLM\n │\n ├── \"Distributed\"\n ├── \"systems\"\n ├── \"are\"\n ├── ...\n │\n ▼\nBrowser\n```\n\nEverything looks great.\n\nThen the user closes the browser tab.\n\nThe response disappears.\n\nBut what about the LLM request?\n\n**Is it still running?**\n\nIf your server doesn't explicitly propagate cancellation, the answer may be yes.\n\nAnd that's not just a correctness problem. For an AI application, it can become a **cost problem**.\n\nA user can abandon a generation after 500 tokens, while your backend continues paying for the remaining thousands of tokens.\n\nIn this article, we'll build the cancellation path for a TypeScript LLM server:\n\n```\nBrowser\n   │\n   │ client disconnect\n   ▼\nHono / Node.js\n   │\n   │ AbortSignal\n   ▼\nfetch()\n   │\n   │ cancellation\n   ▼\nLLM API\n```\n\nAlong the way, we'll look at:\n\n`AbortController`\n\nand `AbortSignal`\n\nworkConsider the simplest possible server:\n\n``` js\napp.post('/api/chat', async (c) => {\n  const response = await fetch(LLM_API_URL, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      Authorization: `Bearer ${API_KEY}`,\n    },\n    body: JSON.stringify({\n      model: '...',\n      messages: [\n        {\n          role: 'user',\n          content: 'Explain distributed systems',\n        },\n      ],\n      stream: true,\n    }),\n  });\n\n  return new Response(response.body, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n    },\n  });\n});\n```\n\nThis works.\n\nThe browser sends a request:\n\n```\nBrowser\n   │\n   │ POST /api/chat\n   ▼\nServer\n   │\n   │ fetch()\n   ▼\nLLM API\n```\n\nThe LLM starts generating tokens, and the server streams them back to the browser.\n\nBut now:\n\n```\nBrowser\n   │\n   X\n   │\n   │ tab closed\n```\n\nThe browser is gone.\n\nWhat happens to the `fetch()`\n\ncall to the LLM?\n\n**Nothing automatically tells your application to cancel it.**\n\nThe server and the upstream LLM request are separate operations.\n\nYour server needs to explicitly connect their lifecycles.\n\nThere are actually two HTTP connections here.\n\n```\n       Connection #1\nBrowser ────────────────► Server\n                             │\n                             │ Connection #2\n                             ▼\n                         LLM API\n```\n\nThe browser controls Connection #1.\n\nYour server controls Connection #2.\n\nWhen the browser closes the tab:\n\n```\nBrowser                  Server                  LLM API\n   │                        │                       │\n   │──── HTTP request ─────►│──── HTTP request ───►│\n   │                        │                       │\n   X                        │                       │\n   │                        │                       │\n   │ connection closed      │                       │\n   │                        │                       │\n                            │                       │\n                            │──── still connected ─►│\n```\n\nThe LLM API doesn't magically know that the browser disappeared.\n\nYour server has to propagate the cancellation:\n\n```\nBrowser\n   X\n   │\n   ▼\nServer\n   │\n   │ abort()\n   ▼\nLLM API\n```\n\nThis is where `AbortController`\n\ncomes in.\n\nThe Web Platform already gives us a standard cancellation mechanism:\n\n``` js\nconst controller = new AbortController();\n\nconst response = await fetch(url, {\n  signal: controller.signal,\n});\n\n// Later:\ncontroller.abort();\n```\n\nThe important part is:\n\n```\nsignal: controller.signal\n```\n\nThe `AbortSignal`\n\nis passed into the operation.\n\nWhen:\n\n```\ncontroller.abort();\n```\n\nis called, APIs that support the signal can terminate the operation.\n\nNode.js supports `AbortSignal`\n\nthroughout its asynchronous APIs, including streams and HTTP-related operations.\n\nThis gives us a useful mental model:\n\n```\nAbortController\n       │\n       │ signal\n       ▼\n ┌─────────────┐\n │ async work  │\n └─────────────┘\n       │\n       │ abort()\n       ▼\n   cancelled\n```\n\nThe controller doesn't need to know what it's cancelling.\n\nIt simply broadcasts:\n\n\"Stop.\"\n\nAny operation that received its signal can react accordingly.\n\nNow we need to answer the first question:\n\nHow does the server know that the browser has disconnected?\n\nWith Hono, the request exposes the underlying request signal:\n\n```\nc.req.raw.signal\n```\n\nThis signal is aborted when the client connection is terminated.\n\nSo we can connect it directly to the LLM request:\n\n``` js\napp.post('/api/chat', async (c) => {\n  const response = await fetch(LLM_API_URL, {\n    method: 'POST',\n    signal: c.req.raw.signal,\n    headers: {\n      'Content-Type': 'application/json',\n      Authorization: `Bearer ${API_KEY}`,\n    },\n    body: JSON.stringify({\n      model: '...',\n      messages: [\n        {\n          role: 'user',\n          content: 'Explain distributed systems',\n        },\n      ],\n      stream: true,\n    }),\n  });\n\n  return new Response(response.body, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n    },\n  });\n});\n```\n\nNow the lifecycle looks like this:\n\n```\nBrowser\n   │\n   │ request\n   ▼\nHono\n   │\n   │ c.req.raw.signal\n   ▼\nfetch()\n   │\n   ▼\nLLM API\n```\n\nIf the browser disconnects:\n\n```\nBrowser\n   X\n   │\n   ▼\nc.req.raw.signal\n   │\n   │ aborted\n   ▼\nfetch()\n   │\n   │ cancelled\n   ▼\nLLM API\n```\n\nThis is the critical connection that many first versions of AI applications miss.\n\nThere is another failure mode.\n\nWhat if the LLM API simply takes too long?\n\nYou don't want a request hanging forever.\n\nSo we have two independent cancellation conditions:\n\n```\n             ┌─────────────────┐\n             │ Client closes   │\n             │ browser         │\n             └────────┬────────┘\n                      │\n                      ▼\n                   CANCEL\n                      ▲\n                      │\n             ┌────────┴────────┐\n             │ Server timeout  │\n             │ 30 seconds      │\n             └─────────────────┘\n```\n\nWe want:\n\nCancel if\n\neithercondition occurs.\n\nModern JavaScript gives us exactly that:\n\n```\nAbortSignal.any()\n```\n\n`AbortSignal.any()`\n\ncreates a signal that aborts when any of the supplied signals aborts. It is available in Node.js 20+ and later versions.\n\nSo:\n\n``` js\nconst timeoutSignal = AbortSignal.timeout(30_000);\n\nconst signal = AbortSignal.any([\n  timeoutSignal,\n  c.req.raw.signal,\n]);\n```\n\nNow one signal represents both conditions.\n\nPutting it together:\n\n``` js\napp.post('/api/chat', async (c) => {\n  const signal = AbortSignal.any([\n    AbortSignal.timeout(30_000),\n    c.req.raw.signal,\n  ]);\n\n  try {\n    const response = await fetch(LLM_API_URL, {\n      method: 'POST',\n      signal,\n      headers: {\n        'Content-Type': 'application/json',\n        Authorization: `Bearer ${API_KEY}`,\n      },\n      body: JSON.stringify({\n        model: '...',\n        messages: [\n          {\n            role: 'user',\n            content: 'Explain distributed systems',\n          },\n        ],\n        stream: true,\n      }),\n    });\n\n    return new Response(response.body, {\n      headers: {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n      },\n    });\n  } catch (error) {\n    if (signal.aborted) {\n      console.log('LLM request cancelled');\n    }\n\n    throw error;\n  }\n});\n```\n\nThere are now two ways the request can terminate:\n\n```\n                  ┌── browser closes\n                  │\n                  │\nAbortSignal.any ──┤\n                  │\n                  │\n                  └── 30s timeout\n                         │\n                         ▼\n                       abort\n                         │\n                         ▼\n                     fetch()\n                         │\n                         ▼\n                      LLM API\n```\n\nThis is much better than manually maintaining separate timers and disconnect handlers.\n\nCancellation becomes particularly important when you're streaming LLM output.\n\nWithout streaming:\n\n```\nBrowser ── request ──► Server ──► LLM\n\n                         20 seconds\n\nBrowser ◄────────────── complete response\n```\n\nWith streaming:\n\n```\nBrowser ◄── token ── token ── token ── token ── ...\n```\n\nThe request may stay alive for tens of seconds or even minutes.\n\nThat creates a much larger cancellation window.\n\nThe architecture is essentially a stream pipeline:\n\n```\nLLM API\n   │\n   │ chunks\n   ▼\nSSE parser\n   │\n   │ events\n   ▼\nTransformStream\n   │\n   │ events\n   ▼\nHTTP response\n   │\n   ▼\nBrowser\n```\n\nThe important thing is that the stream is not a special \"AI\" mechanism.\n\nIt's just a stream-processing pipeline.\n\nNode.js and the Web Streams API provide cancellation mechanisms through `AbortSignal`\n\n, and stream operations can be terminated when their signal is aborted.\n\nThis is why understanding server-side streams is so useful when building AI applications.\n\nThere's another subtle problem with streaming.\n\nSuppose the LLM sends:\n\n```\ndata: Hello\\n\\n\ndata: world\\n\\n\n```\n\nYou might imagine that your HTTP client receives exactly those chunks.\n\nIt doesn't have to.\n\nThe network might give you:\n\n```\ndata: Hel\n```\n\nthen:\n\n```\nlo\\n\\ndata: wor\n```\n\nthen:\n\n```\nld\\n\\n\n```\n\nA TCP chunk is not necessarily an application-level message.\n\nSo your streaming pipeline needs to buffer incomplete data:\n\n```\nNetwork chunks\n      │\n      ▼\n┌──────────────┐\n│    Buffer    │\n└──────┬───────┘\n       │\n       ▼\nComplete SSE events\n       │\n       ▼\nApplication\n```\n\nA `TransformStream`\n\nis a natural fit:\n\n``` js\nfunction createLineTransform(): TransformStream<string, string> {\n  let buffer = '';\n\n  return new TransformStream({\n    transform(chunk, controller) {\n      buffer += chunk;\n\n      const lines = buffer.split('\\n');\n\n      // The last fragment may be incomplete.\n      buffer = lines.pop() ?? '';\n\n      for (const line of lines) {\n        if (line.trim()) {\n          controller.enqueue(line);\n        }\n      }\n    },\n\n    flush(controller) {\n      if (buffer.trim()) {\n        controller.enqueue(buffer);\n      }\n    },\n  });\n}\n```\n\nThe pattern is:\n\n```\nupstream\n   ↓\nchunks\n   ↓\nbuffer\n   ↓\ncomplete messages\n   ↓\nbusiness logic\n```\n\nThis same pattern appears when processing large files, SSE responses, and LLM streaming responses. The key abstraction is **incremental processing**, not AI.\n\nThere's one more reason to treat this as a stream pipeline.\n\nWhat if the producer is faster than the consumer?\n\n```\nLLM\n │\n │ very fast\n ▼\nTransformStream\n │\n │ very fast\n ▼\nNetwork\n │\n │ slow\n ▼\nBrowser\n```\n\nIf data were allowed to accumulate indefinitely, memory usage could grow.\n\nStreams solve this with **backpressure**.\n\nConceptually:\n\n```\nLLM\n │\n ▼\nTransform\n │\n ▼\nNetwork\n │\n ▼\nBrowser\n ▲\n │\n └──── backpressure\n```\n\nWhen the downstream consumer cannot keep up, the stream machinery can stop pushing data upstream until capacity becomes available.\n\nThis is one of the major reasons streams are preferable to accumulating the entire response in memory.\n\nAnd it is the same reason the following two approaches are fundamentally different:\n\n``` js\n// Wait for everything\nconst result = await response.text();\n```\n\nversus:\n\n``` js\n// Process incrementally\nfor await (const chunk of stream) {\n  process(chunk);\n}\n```\n\nFor AI applications, incremental processing is what makes token-by-token responses possible.\n\nNow return to our original question.\n\nSuppose:\n\n```\nUser starts generation\n        │\n        ▼\nLLM generates 5,000 tokens\n        │\n        │\n        ├── User closes tab after 500 tokens\n        │\n        ▼\nServer continues generating\n```\n\nThe exact financial impact depends on the model, provider, request, caching, and billing model.\n\nBut the engineering principle is simple:\n\nIf an operation no longer has a consumer, you should explicitly decide whether the operation should continue.\n\nFor an interactive chat response, continuing is usually wasteful.\n\nThe user isn't going to read tokens that have nowhere to go.\n\nCancellation gives you a way to release the work:\n\n```\n500 tokens generated\n        │\n        ▼\nclient disconnect\n        │\n        ▼\nAbortSignal\n        │\n        ▼\ncancel upstream request\n```\n\nThe benefit isn't only token cost.\n\nYou also release:\n\nHere's the important architectural distinction.\n\n**Client disconnect does not always mean \"cancel the task.\"**\n\nConsider four operations.\n\n```\nUser asks question\n        ↓\nLLM generates response\n        ↓\nUser closes tab\n```\n\nCancel it.\n\nThe result has no value if the user has abandoned it.\n\n```\nUser uploads PDF\n        ↓\nServer starts indexing\n        ↓\nUser closes browser\n```\n\nDon't necessarily cancel it.\n\nThe indexing operation is part of a persistent workflow.\n\nThe user's browser is merely observing the operation.\n\n```\nUser submits form\n        ↓\nServer writes database\n        ↓\nBrowser disconnects\n```\n\nUsually, you want the database operation to complete.\n\nThe database write is a business operation, not a streaming response.\n\nConsider an Agent run:\n\n```\nUser\n │\n ▼\nPOST /agent/run\n │\n ▼\nAgent\n │\n ├── search\n ├── browse\n ├── call tools\n ├── generate\n └── ...\n```\n\nThis might take several minutes.\n\nBinding the entire Agent lifecycle to an HTTP connection is usually the wrong architecture.\n\nInstead:\n\n```\nPOST /agent/run\n        │\n        ▼\n      taskId\n        │\n        ▼\nbackground worker\n        │\n        ├── tool calls\n        ├── LLM calls\n        └── state\n```\n\nThe frontend can then subscribe to the task:\n\n```\nBrowser ───────► task status\n       ◄──────── SSE / WebSocket / polling\n```\n\nNow closing the browser doesn't necessarily destroy the Agent run.\n\nThis distinction is important:\n\nCancellation is a business decision, not merely a technical decision.\n\nInstead of thinking:\n\n\"The browser disconnected, so cancel everything.\"\n\nThink:\n\n\"What is the lifecycle of this operation?\"\n\nThere are two fundamentally different types of work:\n\n```\n                  Operation\n                      │\n          ┌───────────┴───────────┐\n          │                       │\n     Connection-bound        Task-bound\n          │                       │\n          ▼                       ▼\n    Chat streaming           Agent run\n    autocomplete             file indexing\n    live response             database write\n          │                       │\n          ▼                       ▼\n    disconnect → cancel      disconnect → continue\n```\n\nThis distinction becomes increasingly important as an AI application grows.\n\nCancellation is not necessarily a normal application error.\n\nFor example:\n\n```\ntry {\n  await fetch(url, { signal });\n} catch (error) {\n  if (signal.aborted) {\n    // Expected cancellation\n    return;\n  }\n\n  throw error;\n}\n```\n\nCompare that with:\n\n```\n429 Rate Limit\n502 Upstream Failure\n500 Internal Error\nAbortError\n```\n\nThese represent different things.\n\nA production server should distinguish:\n\nA useful error hierarchy might look like:\n\n```\nAppError\n├── ValidationError\n├── UnauthorizedError\n├── RateLimitError\n├── ExternalServiceError\n└── ...\n```\n\nThen a global error handler can convert known application failures into consistent API responses while unexpected programmer errors are logged separately.\n\nThis kind of centralized error handling is especially important on servers because an unhandled error can affect many users rather than just one browser tab.\n\nPutting everything together:\n\n```\n                         Browser\n                            │\n                            │ POST /chat\n                            ▼\n                    ┌───────────────┐\n                    │     Hono      │\n                    │               │\n                    │ Zod validate  │\n                    └───────┬───────┘\n                            │\n                            ▼\n                  ┌───────────────────┐\n                  │   AbortSignal     │\n                  │                   │\n                  │ client disconnect │\n                  │        OR         │\n                  │ 30s timeout       │\n                  └─────────┬─────────┘\n                            │\n                            ▼\n                     ┌────────────┐\n                     │  fetch()   │\n                     └─────┬──────┘\n                           │\n                           │ streaming\n                           ▼\n                     ┌────────────┐\n                     │  LLM API   │\n                     └─────┬──────┘\n                           │\n                           │ chunks\n                           ▼\n                    ┌────────────────┐\n                    │ TransformStream│\n                    │                │\n                    │ parse / buffer │\n                    └───────┬────────┘\n                            │\n                            │ SSE\n                            ▼\n                         Browser\n```\n\nThere are several independent pieces here:\n\n**Type safety**\n\n```\nZod → validated input → typed route\n```\n\n**Streaming**\n\n```\nLLM → chunks → TransformStream → SSE\n```\n\n**Cancellation**\n\n```\ndisconnect ─┐\n            ├─→ AbortSignal → fetch()\ntimeout ────┘\n```\n\n**Error handling**\n\n```\nLLM / application errors\n          ↓\n     error hierarchy\n          ↓\n    global handler\n          ↓\n    consistent API\n```\n\nNone of these mechanisms is specifically an \"AI framework.\"\n\nThey're server-side engineering primitives.\n\nAnd that's exactly why they matter.\n\nWhen you first build an LLM application, it's tempting to think about the model first:\n\n```\nWhich model?\nWhich prompt?\nWhich agent framework?\nWhich vector database?\n```\n\nBut once the application has real users, many of the expensive problems are much less glamorous:\n\n```\nWhat happens when the user disconnects?\n\nWhat happens when the model takes 2 minutes?\n\nWhat happens when the provider returns 429?\n\nWhat happens when the browser can't consume the stream fast enough?\n\nWhat happens when the request times out?\n\nWhat happens when an Agent outlives the HTTP connection?\n```\n\nThese are **server engineering problems**.\n\nAnd TypeScript gives you excellent primitives for solving them:\n\n```\nPromises\nStreams\nTransformStream\nAbortController\nAbortSignal\nasync iterators\ntyped errors\nZod\nHono\n```\n\nOnce you understand these primitives, an LLM streaming server stops looking like mysterious AI infrastructure.\n\nIt's just a carefully designed asynchronous pipeline.\n\nAnd that is the important shift:\n\nAI engineering is still software engineering.\n\nThe model may be probabilistic.\n\nThe server shouldn't be.\n\nIf you want to go deeper into the underlying primitives, the Node.js documentation covers `AbortSignal`\n\n, stream cancellation, and Web Streams in detail.\n\nThis article is also based on the **server-side TypeScript patterns covered in Chapter 3 of 《AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents》at Leanpub**, particularly the sections on Streams, cancellation, and type-safe API design. The chapter explicitly treats LLM streaming as an instance of the same streaming model used elsewhere in Node.js rather than as a separate AI-specific abstraction.\n\nIf you're building AI applications with TypeScript, these are the foundations worth understanding before adding more sophisticated agent frameworks.", "url": "https://wpnews.pro/news/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab", "canonical_source": "https://dev.to/kristinz/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab-4k01", "published_at": "2026-08-22 12:01:50+00:00", "updated_at": "2026-08-22 12:13:23.113103+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools"], "entities": ["TypeScript", "Hono", "Node.js", "AbortController", "AbortSignal", "LLM API"], "alternates": {"html": "https://wpnews.pro/news/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab", "markdown": "https://wpnews.pro/news/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab.md", "text": "https://wpnews.pro/news/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab.txt", "jsonld": "https://wpnews.pro/news/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab.jsonld"}}