Your Node Service Is Still Paying for LLM Calls After Users Close the Tab

After the user closes the tab, your Node handler keeps paying—thread AbortSignal from socket to every call.
The article exposes a hidden cost leak: when a user closes the browser tab, Node's async handler has no idea—the model stream, tool calls, and agent turns all keep running and billing, and because nothing crashes, nothing alerts. The fix is threading `AbortSignal` from socket detection down to every call that costs money. Detection listens on ServerResponse's close event combined with writableFinished to tell a real disconnect from a completed response, rather than the semantics-changed req.on('close') or the deprecated 'aborted'. Propagation uses AbortSignal.any to merge the disconnect signal with a timeout, and hands the same signal to the SDK, fetch, and each agent turn's entry point. It also highlights an easy pitfall: Anthropic's stream iterator can end silently on abort, so handling aborts only in catch can miss logging—or even save a truncated reply as a complete answer—making a post-loop `signal.aborted` check mandatory.
A user requests a full migration plan, realizes three seconds later that the answer is going in the wrong direction, and closes the tab. The browser disconnects, but the Node handler has no idea. It keeps pulling tokens from the model stream, keeps writing data into a socket no one is reading; when the model requests a tool call, it dutifully executes the tool and sends the whole conversation back for the next turn.
Nothing crashes, so nothing alerts. The only trace is on the bill.
Node won't cancel any of this for you. A handler is just an async function, and it runs until it returns. To make the work stop when the user leaves, you have to carry that fact from the socket all the way into every call that costs money. The tool for doing this is an AbortSignal passed all the way down.
What Keeps Running After the User Leaves
Without any handling, three things survive after the user leaves.
The model stream. The server maintains a long-lived HTTP connection to the provider and keeps reading. As long as you keep reading, the model keeps generating until it finishes naturally or hits max_tokens.
Tool calls. If the model asks for a search or a database query, your code executes it. The results flow into a dead end.
Subsequent turns. In an agent loop, each turn resends the entire accumulated conversation as input. Every turn you start is a paid input plus output. The turn you never start costs zero. This last point holds regardless of the provider.
What happens to output tokens after you close the upstream connection is murkier. Neither OpenAI's nor Anthropic's streaming docs state it clearly. One AI gateway vendor, TrueFoundry, published a provider-by-provider cancellation billing policy: it lists Vertex AI and Azure OpenAI as "confirmed not billed for cancelled streams," and says OpenAI "bills for the full completion whether or not you keep reading." That table is based on their own measurements, but the underlying data isn't public and there's no citable source. On the OpenAI community forum, some developers claim the opposite, though the same thread also notes that input context is still billed. Treat the output side as provider-dependent and check your own usage logs. Cancel regardless: skipped turns and tool calls are real savings no matter who's right.
, and it stopped meaning "socket closed" years ago. The Node.js HTTP docs record this change (v16.0.0): "The 'close' event is now emitted when the request has been completed and not when the underlying socket is closed." For a POST, that means req fires close the moment you finish reading the body. Using that to abort cancels every single request. The old 'aborted' event is deprecated, with the docs noting to "listen for the 'close' event instead."
Listen on the response instead. The ServerResponse close event "indicates that the response has been completed or that its underlying connection was terminated prematurely." To distinguish the two, check writableFinished—it's only true once all data has been flushed:
// disconnect.ts
import type { ServerResponse } from "node:http";
export function disconnectSignal(
res: ServerResponse,
): AbortSignal {
const ac = new AbortController();
res.on("close", () => {
if (!res.writableFinished) {
ac.abort("client_disconnected");
}
});
return ac.signal;
}
This works under Express too, since its res is an enhanced version of Node's native response object. Combined with AbortSignal.any (introduced in Node v20.3.0 and v18.17.0) and AbortSignal.timeout for a hard deadline, you get a signal with two trigger paths.
If you're running behind nginx, it already closes the upstream connection when the client leaves: proxy_ignore_client_abort defaults to off. If someone set it to on, your Node service will never see the disconnect, and none of the above will fire.
Why req.on('close') misleads you: This change stems from Node v16.0.0. In earlier versions, IncomingMessage's close event fired when the underlying TCP socket closed, so it was intuitively treated as a "client disconnected" signal. But the Node team refactored the HTTP lifecycle so that the close event aligns with the end of the HTTP message itself—for a request object, that's when the request body has been fully read. For a POST request, as soon as the for await (const chunk of req) loop exits, req's close fires, even though the connection may still be perfectly open. Using that to abort() means cancelling yourself the moment you finish reading the input of every request.
The correct replacement is listening on the ServerResponse's close event, whose semantics in the Node docs are "the response has been completed or its underlying connection was terminated prematurely"—that mixture of two cases is the tricky part. The way to tell them apart is to check res.writableFinished: it's only true once all response data has been flushed and end() has been called. So the logic is: close fires and writableFinished === false → the client left early. The old 'aborted' event is marked deprecated in the docs, which explicitly say to "listen for the 'close' event instead."
Passing the Signal into the SDK
Both official SDKs accept signal in their per-request options. The Anthropic TypeScript SDK documents it as "a way to cancel the request using an AbortSignal," and the OpenAI Node SDK README says the same. Here's a streaming route using the Anthropic SDK, written with native node:http so the plumbing is exposed:
// server.ts (Node 20+)
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import Anthropic from "@anthropic-ai/sdk";
import { disconnectSignal } from "./disconnect.js";
const client = new Anthropic();
const MODEL = "claude-opus-5-5";
const MAX_TOKENS = 2048;
type Stats = { input: number; outChars: number };
async function readPrompt(req: IncomingMessage) {
let raw = "";
for await (const chunk of req) raw += chunk;
return (JSON.parse(raw) as { prompt: string }).prompt;
}
The handler combines the disconnect signal with a two-minute deadline into one signal, then hands it to the SDK:
// server.ts (continued)
async function chat(req: IncomingMessage, res: ServerResponse) {
const signal = AbortSignal.any([
disconnectSignal(res),
AbortSignal.timeout(120_000),
]);
const prompt = await readPrompt(req);
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
});
const stats: Stats = { input: 0, outChars: 0 };
try {
const stream = await client.messages.create(
{
model: MODEL,
max_tokens: MAX_TOKENS,
stream: true,
messages: [{ role: "user", content: prompt }],
},
{ signal },
);
for await (const ev of stream) {
if (ev.type === "message_start") {
stats.input = ev.message.usage.input_tokens;
} else if (
ev.type === "content_block_delta" &&
ev.delta.type === "text_delta"
) {
stats.outChars += ev.delta.text.length;
res.write(`data: ${JSON.stringify(ev.delta.text)}\n\n`);
}
}
} catch (err) {
if (!signal.aborted) throw err;
}
if (signal.aborted) {
logAborted("/chat", stats, signal.reason);
if (!res.writableEnded) {
res.end("event: aborted\ndata: {}\n\n");
}
return;
}
res.end("event: done\ndata: {}\n\n");
}
The res.end in the abort branch matters for the timeout path: the client may still be connected, and without calling it the SSE response stays open forever. On an already-closed socket it does nothing. Finally, the server itself:
// server.ts (continued)
http
.createServer((req, res) => {
chat(req, res).catch((err) => {
console.error(err);
res.destroy();
});
})
.listen(3000);
The signal.aborted check after the for await loop in chat is deliberate. Inside the SDK's stream iterator, abort errors are caught and annotated with "if the user calls stream.controller.abort(), we should exit without throwing." So when the signal fires mid-stream, your for await loop may end silently, as if the model finished normally. Code that only handles aborts in catch will never log it. Worse, code that saves the answer right after the loop will store a truncated reply as a complete one. Always check signal.aborted after every loop.
The same file shows the other direction: if you break out of the loop, the finally block automatically aborts the request for you. In this SDK, "stop reading" and "cancel" are the same operation.
message_start carries the input token count, while the final output count only appears in message_delta near the end of the stream—which an aborted stream never reaches. That's why the route falls back to character counting.
AbortSignal and AbortController are standard cancellation primitives introduced by the web platform and later adopted natively by Node.js. AbortController is a controller object that holds an AbortSignal internally; calling controller.abort(reason) switches the signal from un-aborted to aborted and exposes reason as signal.reason. All listeners previously registered via signal.addEventListener("abort", ...) fire. The key property is that this signal can be passed down by reference through layer after layer: the HTTP handler hands it to the SDK, the SDK hands it to fetch, and fetch hands it to the underlying socket. Any layer that listens to the same signal stops synchronously the moment the user disconnects. This is why the implementation here isn't "passing a Boolean flag down through layers" but instead passing the same AbortSignal object all the way to the bottom.
AbortSignal.any and AbortSignal.timeout are two static composition utilities. AbortSignal.timeout(ms) returns a signal that automatically aborts after the given number of milliseconds; AbortSignal.any([...]) takes an array of signals and returns a signal that aborts as soon as any one of them aborts. Combining the two turns "user disconnected" and "hard timeout" into two trigger paths for the same signal—downstream code doesn't need to know which one fired, it just checks if (signal.aborted). This feature requires Node v20.3.0 or v18.17.0, matching the Node 20+ declared by the examples in the article.
Tool Calls and the Agent Loop
A single stream is the small case. The expensive part is the loop, because every turn resends everything. Pass the same signal to the model call, to every tool, and check it before every turn begins. The following code goes in the same file, reusing client, MODEL, and MAX_TOKENS. First, the tool:
// server.ts (continued)
const MAX_TURNS = 8;
const tools: Anthropic.Tool[] = [{
name: "search_docs",
description: "Search product documentation",
input_schema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
}];
type AgentStats = { input: number; output: number; turns: number };
async function runTool(
name: string,
input: unknown,
signal: AbortSignal,
): Promise<string> {
if (name !== "search_docs") return `unknown tool: ${name}`;
const { query } = input as { query: string };
const url = new URL("https://docs.example.com/search");
url.searchParams.set("q", query);
const r = await fetch(url, { signal });
return await r.text();
}
Then the loop:
// server.ts (continued)
async function runAgent(
messages: Anthropic.MessageParam[],
signal: AbortSignal,
stats: AgentStats,
): Promise<Anthropic.Message | undefined> {
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (signal.aborted) return undefined;
const msg = await client.messages.create(
{ model: MODEL, max_tokens: MAX_TOKENS, tools, messages },
{ signal },
);
stats.turns += 1;
stats.input += msg.usage.input_tokens;
stats.output += msg.usage.output_tokens;
if (msg.stop_reason !== "tool_use") return msg;
const calls = msg.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
const results = await Promise.all(
calls.map(async (c) => ({
type: "tool_result" as const,
tool_use_id: c.id,
content: await runTool(c.name, c.input, signal),
})),
);
messages.push({ role: "assistant", content: msg.content });
messages.push({ role: "user", content: results });
}
return undefined;
}
fetch accepts a signal natively, so in-flight tool requests vanish along with the user. For tools that don't accept a signal (CPU loops, legacy clients), call signal.throwIfAborted() between steps, as documented in the Node globals reference. The if (signal.aborted) at the top of each turn is the cheapest line in the file: it's the one that stops an entire full-context call from ever starting. Mid-turn, the SDK call and fetch reject with an abort error, so wrap the caller the same way as the streaming route: catch, check signal.aborted, log stats.turns. The shape of this tool loop follows Anthropic's tool use guide.

function logAborted(route: string, s: Stats, reason: unknown) {
const outEst = Math.ceil(s.outChars / 4);
console.log(JSON.stringify({
event: "llm.aborted",
route,
reason: String(reason),
input_tokens: s.input,
output_tokens_est: outEst,
output_budget_unused: Math.max(0, MAX_TOKENS - outEst),
}));
}
Dividing by four comes from OpenAI's own rule of thumb: English text is about 4 characters per token. It's an estimate; other tokenizers differ. output_budget_unused is an upper bound on what you avoided—and only if the provider stops generating when the connection closes. For the agent route, log the turns from AgentStats at the moment of abort, then compare against the average turns of completed runs. The difference times your average per-turn input is the savings you can defend in a cost review.
Then aggregate: count aborted requests by route and by day, and what fraction of all requests they represent. If a large share of chat requests end with client_disconnected, that's both a cost signal and a product signal: users are leaving before the answer lands.
Why you can't precisely record tokens saved: Token billing is settled by the number of tokens the provider actually generated; tokens never generated have no line item. The /4 estimate in the article comes from OpenAI's own tokenizer rule of thumb—English text averages about 4 characters per token. This conversion drifts noticeably on content with lots of Chinese, code, or Markdown structure, because different tokenizers segment CJK characters and symbols at very different granularities (the same Chinese character may cost 1 or 2 tokens depending on the model). So output_tokens_est should be understood as a ruler, not a ledger; its value is making the magnitude of aborted requests visible, not serving as a basis for bill reconciliation.
The only number you can actually commit to externally is a structural metric like turns: the turn count at which the abort happened, compared against the average turns of completed runs, multiplied by average input tokens per turn. That's the part worth defending in a cost review. This framing doesn't depend on judging whether the provider bills unread output—whichever side is right, the turns that never started cost zero.
Two Boundaries to Check Before Shipping
The browser side has to actually close the connection. If your UI has a stop button, wire it to an AbortController on the fetch call, so "stop" becomes a real disconnect rather than just hiding text:
const ac = new AbortController();
stopButton.onclick = () => ac.abort();
await fetch("/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: ac.signal,
});
Background tasks, though, are a different contract. OpenAI's Responses API has a background mode designed to let the response survive a disconnected connection; you can resume the stream from a sequence_number cursor. Closing the socket there isn't a cancellation. The cancel endpoint is, and "only responses created with the background parameter set to true can be cancelled." If you use background mode, your abort listener should call client.responses.cancel(id) instead of relying on the disconnect.
To try this route locally, start the server, send a long prompt with curl and -N, then hit Ctrl-C after a second. You should see a llm.aborted line and no event: done.
Conclusion
Cancellation never shows up in the demo, but it always shows up on the bill. Threading AbortSignal from the socket into every call that costs money is discipline built line by line: listen on res instead of req, check writableFinished, check aborted once more after the loop, and hand the signal to the SDK, to fetch, and to the entry point of every turn.
Related articles

Bilibili's Hit Dify Tutorial: A Practical Guide to Building 20+ AI Apps from Scratch
A distilled guide to Bilibili's top-rated Dify tutorial: Windows + Docker deployment, MySQL connection, five app types, model integration strategy, and publishing.

Agent Skills Beginner's Guide: From Concepts to Multi-Agent Collaboration in Practice
A comprehensive guide to Agent Skills: core concepts, components, the importance of Description, standardization, beginner learning paths, and multi-agent collaboration.

One Neuron Detects Both Cats and Cars? We Reproduced It and Found a New Explanation
The cat-car neuron reinterpreted: researcher Cammarata argues it distinguishes pointy from round, not cats from cars. We reproduced the experiment and found the new explanation holds on most inputs—but cars are the exception. The interpretability debate continues on language models.