cd /news/large-language-models/your-llm-app-is-wasting-money-what-h… Β· home β€Ί topics β€Ί large-language-models β€Ί article
[ARTICLE Β· art-107029] src=dev.to β†— pub= topic=large-language-models verified=true sentiment=Β· neutral

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.

read12 min views1 publishedAug 22, 2026

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:

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:

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:

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:

const timeoutSignal = AbortSignal.timeout(30_000);

const signal = AbortSignal.any([
  timeoutSignal,
  c.req.raw.signal,
]);

Now one signal represents both conditions.

Putting it together:

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:

function createLineTransform(): TransformStream<string, string> {
  let buffer = '';

  return new TransformStream({
    transform(chunk, controller) {
      buffer += chunk;

      const lines = buffer.split('\n');

      // The last fragment may be incomplete.
      buffer = lines.pop() ?? '';

      for (const line of lines) {
        if (line.trim()) {
          controller.enqueue(line);
        }
      }
    },

    flush(controller) {
      if (buffer.trim()) {
        controller.enqueue(buffer);
      }
    },
  });
}

The pattern is:

upstream
   ↓
chunks
   ↓
buffer
   ↓
complete messages
   ↓
business logic

This same pattern appears when processing large files, SSE responses, and LLM streaming responses. The key abstraction is incremental processing, not AI.

There's one more reason to treat this as a stream pipeline.

What if the producer is faster than the consumer?

LLM
 β”‚
 β”‚ very fast
 β–Ό
TransformStream
 β”‚
 β”‚ very fast
 β–Ό
Network
 β”‚
 β”‚ slow
 β–Ό
Browser

If data were allowed to accumulate indefinitely, memory usage could grow.

Streams solve this with backpressure.

Conceptually:

LLM
 β”‚
 β–Ό
Transform
 β”‚
 β–Ό
Network
 β”‚
 β–Ό
Browser
 β–²
 β”‚
 └──── backpressure

When the downstream consumer cannot keep up, the stream machinery can stop pushing data upstream until capacity becomes available.

This is one of the major reasons streams are preferable to accumulating the entire response in memory.

And it is the same reason the following two approaches are fundamentally different:

// Wait for everything
const result = await response.text();

versus:

// Process incrementally
for await (const chunk of stream) {
  process(chunk);
}

For AI applications, incremental processing is what makes token-by-token responses possible.

Now return to our original question.

Suppose:

User starts generation
        β”‚
        β–Ό
LLM generates 5,000 tokens
        β”‚
        β”‚
        β”œβ”€β”€ User closes tab after 500 tokens
        β”‚
        β–Ό
Server continues generating

The exact financial impact depends on the model, provider, request, caching, and billing model.

But the engineering principle is simple:

If an operation no longer has a consumer, you should explicitly decide whether the operation should continue.

For an interactive chat response, continuing is usually wasteful.

The user isn't going to read tokens that have nowhere to go.

Cancellation gives you a way to release the work:

500 tokens generated
        β”‚
        β–Ό
client disconnect
        β”‚
        β–Ό
AbortSignal
        β”‚
        β–Ό
cancel upstream request

The benefit isn't only token cost.

You also release:

Here's the important architectural distinction.

Client disconnect does not always mean "cancel the task."

Consider four operations.

User asks question
        ↓
LLM generates response
        ↓
User closes tab

Cancel it.

The result has no value if the user has abandoned it.

User uploads PDF
        ↓
Server starts indexing
        ↓
User closes browser

Don't necessarily cancel it.

The indexing operation is part of a persistent workflow.

The user's browser is merely observing the operation.

User submits form
        ↓
Server writes database
        ↓
Browser disconnects

Usually, you want the database operation to complete.

The database write is a business operation, not a streaming response.

Consider an Agent run:

User
 β”‚
 β–Ό
POST /agent/run
 β”‚
 β–Ό
Agent
 β”‚
 β”œβ”€β”€ search
 β”œβ”€β”€ browse
 β”œβ”€β”€ call tools
 β”œβ”€β”€ generate
 └── ...

This might take several minutes.

Binding the entire Agent lifecycle to an HTTP connection is usually the wrong architecture.

Instead:

POST /agent/run
        β”‚
        β–Ό
      taskId
        β”‚
        β–Ό
background worker
        β”‚
        β”œβ”€β”€ tool calls
        β”œβ”€β”€ LLM calls
        └── state

The frontend can then subscribe to the task:

Browser ───────► task status
       ◄──────── SSE / WebSocket / polling

Now closing the browser doesn't necessarily destroy the Agent run.

This distinction is important:

Cancellation is a business decision, not merely a technical decision.

Instead of thinking:

"The browser disconnected, so cancel everything."

Think:

"What is the lifecycle of this operation?"

There are two fundamentally different types of work:

                  Operation
                      β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚                       β”‚
     Connection-bound        Task-bound
          β”‚                       β”‚
          β–Ό                       β–Ό
    Chat streaming           Agent run
    autocomplete             file indexing
    live response             database write
          β”‚                       β”‚
          β–Ό                       β–Ό
    disconnect β†’ cancel      disconnect β†’ continue

This distinction becomes increasingly important as an AI application grows.

Cancellation is not necessarily a normal application error.

For example:

try {
  await fetch(url, { signal });
} catch (error) {
  if (signal.aborted) {
    // Expected cancellation
    return;
  }

  throw error;
}

Compare that with:

429 Rate Limit
502 Upstream Failure
500 Internal Error
AbortError

These represent different things.

A production server should distinguish:

A useful error hierarchy might look like:

AppError
β”œβ”€β”€ ValidationError
β”œβ”€β”€ UnauthorizedError
β”œβ”€β”€ RateLimitError
β”œβ”€β”€ ExternalServiceError
└── ...

Then a global error handler can convert known application failures into consistent API responses while unexpected programmer errors are logged separately.

This kind of centralized error handling is especially important on servers because an unhandled error can affect many users rather than just one browser tab.

Putting everything together:

                         Browser
                            β”‚
                            β”‚ POST /chat
                            β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚     Hono      β”‚
                    β”‚               β”‚
                    β”‚ Zod validate  β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚   AbortSignal     β”‚
                  β”‚                   β”‚
                  β”‚ client disconnect β”‚
                  β”‚        OR         β”‚
                  β”‚ 30s timeout       β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β–Ό
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚  fetch()   β”‚
                     β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                           β”‚ streaming
                           β–Ό
                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                     β”‚  LLM API   β”‚
                     β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                           β”‚ chunks
                           β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ TransformStreamβ”‚
                    β”‚                β”‚
                    β”‚ parse / buffer β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
                            β”‚ SSE
                            β–Ό
                         Browser

There are several independent pieces here:

Type safety

Zod β†’ validated input β†’ typed route

Streaming

LLM β†’ chunks β†’ TransformStream β†’ SSE

Cancellation

disconnect ─┐
            β”œβ”€β†’ AbortSignal β†’ fetch()
timeout β”€β”€β”€β”€β”˜

Error handling

LLM / application errors
          ↓
     error hierarchy
          ↓
    global handler
          ↓
    consistent API

None of these mechanisms is specifically an "AI framework."

They're server-side engineering primitives.

And that's exactly why they matter.

When you first build an LLM application, it's tempting to think about the model first:

Which model?
Which prompt?
Which agent framework?
Which vector database?

But once the application has real users, many of the expensive problems are much less glamorous:

What happens when the user disconnects?

What happens when the model takes 2 minutes?

What happens when the provider returns 429?

What happens when the browser can't consume the stream fast enough?

What happens when the request times out?

What happens when an Agent outlives the HTTP connection?

These are server engineering problems.

And TypeScript gives you excellent primitives for solving them:

Promises
Streams
TransformStream
AbortController
AbortSignal
async iterators
typed errors
Zod
Hono

Once you understand these primitives, an LLM streaming server stops looking like mysterious AI infrastructure.

It's just a carefully designed asynchronous pipeline.

And that is the important shift:

AI engineering is still software engineering.

The model may be probabilistic.

The server shouldn't be.

If you want to go deeper into the underlying primitives, the Node.js documentation covers AbortSignal

, stream cancellation, and Web Streams in detail.

This 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.

If you're building AI applications with TypeScript, these are the foundations worth understanding before adding more sophisticated agent frameworks.

── more in #large-language-models 4 stories Β· sorted by recency
── more on @typescript 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/your-llm-app-is-wast…] indexed:0 read:12min 2026-08-22 Β· β€”