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.