Cerrar una pestaña no cancela automáticamente la solicitud que tu servidor hizo al LLM. El navegador puede abandonar el stream mientras el backend sigue esperando tokens, mantiene una conexión abierta o prepara otro intento.
La solución es tratar el ciclo HTTP como parte del runtime. Cuando la respuesta del servidor se cierra antes de terminar, aborta un AbortController, pasa su señal al SDK o a fetch que habla con el LLM y elimina los listeners cuando la respuesta termina normalmente. La guía sobre cancelar un agente cuando una tool se bloquea cubre la política general; este artículo se concentra en la frontera entre cliente, servidor y upstream.
Este artículo usa Node.js y TypeScript para explicar el mecanismo. El código del adapter es ilustrativo y no se ejecutó contra un proveedor real en este repositorio. La documentación del OpenAI Node SDK confirma que un AbortSignal puede cancelar una solicitud, incluso mientras se lee el cuerpo de la respuesta. El mismo patrón se puede adaptar a otro SDK que acepte cancelación cooperativa.
Regla corta
- Dejar de esperar una Promise no es lo mismo que abortar el I/O que esa Promise espera.
- El cierre de la respuesta debe llegar al
AbortSignaldel upstream.- Una respuesta completada normalmente no debe provocar cancelación ni dejar listeners conectados.
- Cancelar la espera no deshace un efecto externo que ya fue aceptado.
¿Qué hay que cancelar?
Hay tres capas distintas en un endpoint de streaming:
- la conexión entre el cliente y tu servidor;
- la solicitud del servidor al modelo, normalmente un stream o una solicitud HTTP;
- cualquier tool, consulta o worker que el agente haya iniciado antes de que el cliente se fuera.
Un res.end() o un return dentro del handler resuelve solo la primera capa. Si la solicitud al modelo sigue viva, el proceso continúa consumiendo recursos. Si una tool ya publicó un mensaje o escribió un registro, ningún AbortSignal puede garantizar que el efecto haya desaparecido.
El runtime necesita propagar la cancelación de forma cooperativa: el evento de desconexión aborta la señal y cada operación que pueda esperar I/O la observa. La guía para transmitir el progreso de un agente a una interfaz cubre el contrato de eventos que llega al navegador. La decisión de detener el trabajo pertenece al runtime, antes de que el evento de cierre llegue a la interfaz.
Convierte el cierre HTTP en un AbortSignal
El servidor debe crear un controller por solicitud. En Express, una combinación útil es observar close en la respuesta y comprobar writableEnded: close también participa en el cierre normal, así que no debes abortar una respuesta que ya terminó. El ejemplo oficial de OpenAI también observa una solicitud abortada y elimina los listeners al final.
import type { Request, Response } from "express";
type Disconnect = {
signal: AbortSignal;
cleanup: () => void;
};
export function watchClientDisconnect(
req: Request,
res: Response,
): Disconnect {
const controller = new AbortController();
const abort = (reason: string) => {
if (!controller.signal.aborted) {
controller.abort(new Error(reason));
}
};
const onRequestAborted = () => abort("request_aborted");
const onResponseClose = () => {
if (!res.writableEnded) abort("client_disconnected");
};
// The request event covers an aborted incoming body.
req.once("aborted", onRequestAborted);
// The response event covers a client leaving during the stream.
res.once("close", onResponseClose);
return {
signal: controller.signal,
cleanup() {
req.off("aborted", onRequestAborted);
res.off("close", onResponseClose);
},
};
}
El manual de Node.js marca el evento aborted de IncomingMessage como deprecated en las versiones actuales y recomienda el camino close del request. Los frameworks y adapters pueden exponer nombres distintos. Encapsula el detalle en una función pequeña y comprueba el contrato del runtime que realmente sirve el endpoint.
cleanup no es un detalle cosmético. Sin él, cada solicitud completada puede dejar una función conectada a un objeto de respuesta. El listener tampoco debe abortar la solicitud después de que el servidor haya ejecutado res.end() correctamente.
Pasa la señal al stream del LLM
El controller solo tiene efecto si el adapter recibe la señal. En el SDK oficial de OpenAI, la señal va en las opciones de la solicitud. En un cliente basado en fetch, va en RequestInit. El nombre cambia según la biblioteca, pero la frontera es la misma.
import OpenAI from "openai";
const openai = new OpenAI();
async function streamAnswer(
prompt: string,
signal: AbortSignal,
): Promise<AsyncIterable<{ delta: string }>> {
const stream = await openai.chat.completions.create(
{
model: "gpt-5.5",
stream: true,
messages: [{ role: "user", content: prompt }],
},
{ signal },
);
return (async function* () {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) yield { delta };
}
})();
}
El tipo del stream cambia según el SDK y la versión instalada, por lo que el cast anterior solo marca una frontera ilustrativa. En producción, usa el tipo exportado por la biblioteca y trata el error de abort como un camino esperado. No conviertas la cancelación del cliente en un error de aplicación ni inicies un retry automático solo porque la Promise fue rechazada.
Un handler mínimo debe comprobar la señal antes de iniciar la llamada, durante la iteración y antes de escribir en la respuesta:
app.post("/api/chat", async (req, res, next) => {
const disconnect = watchClientDisconnect(req, res);
try {
if (disconnect.signal.aborted || res.destroyed) return;
const stream = await streamAnswer(req.body.prompt, disconnect.signal);
res.setHeader("Content-Type", "text/plain; charset=utf-8");
for await (const chunk of stream) {
if (disconnect.signal.aborted || res.destroyed) break;
res.write(chunk.delta);
}
if (!disconnect.signal.aborted && !res.destroyed) res.end();
} catch (error) {
if (disconnect.signal.aborted) return;
next(error);
} finally {
disconnect.cleanup();
}
});
El ejemplo de streaming de OpenAI Node usa la misma idea: observar la desconexión, pasar la señal al stream, evitar nuevas escrituras y limpiar los listeners. Un handler real también debe tener en cuenta si ya se enviaron los headers y el formato de error del framework.
Combina desconexión y deadline
El cliente no es el único motivo para detener un run. Un deadline del servidor protege el endpoint cuando el cliente sigue conectado, pero el upstream no avanza. Node.js ofrece AbortSignal.timeout() y AbortSignal.any() para combinar motivos:
function withDeadline(clientSignal: AbortSignal, totalMs: number) {
const deadline = AbortSignal.timeout(totalMs);
return AbortSignal.any([clientSignal, deadline]);
}
const signal = withDeadline(disconnect.signal, 30_000);
const stream = await streamAnswer(prompt, signal);
El manual de globals de Node.js explica que AbortSignal.any() aborta cuando se aborta cualquiera de las señales de la lista y conserva la razón que provocó el cierre. Registra esa razón como client_disconnected, deadline o server_shutdown. Un mensaje genérico como request failed pierde la información necesaria para decidir si el run puede reanudarse.
Para un agente que llama tools después de cada respuesta del modelo, pasa la misma señal a cada etapa. La guía sobre pausar y reanudar un agente ayuda cuando la intención es continuar el trabajo más tarde, en lugar de cancelar el run conectado a la interfaz.
No crees un retry fantasma
Un abort puede aparecer como una Promise rechazada por el SDK, un error del stream o APIUserAbortError. Clasifícalo antes de ejecutar la política de retry:
| Situación | Estado del endpoint | Próxima acción |
|---|---|---|
| El cliente se fue antes del primer token | cancelled_by_client |
Terminar el stream y no reintentar automáticamente |
| El cliente se fue durante el stream | cancelled_by_client |
Abortar el upstream y registrar la etapa interrumpida |
| Venció el deadline con el cliente conectado | timed_out |
Terminar o transferir a ejecución durable según el producto |
El proveedor respondió 5xx antes de un efecto externo |
upstream_failed |
Reintentar un número limitado de veces solo si la política lo permite |
| Una tool de escritura se interrumpió sin confirmación | effect_uncertain |
Consultar el estado externo antes de reintentar |
El OpenAI Node SDK documenta la cancelación con AbortSignal y los retries para fallos transitorios. La señal también debe cubrir la espera del retry, y la capa de retry debe entender que el cliente ya no quiere esa respuesta. Cancelar el stream no autoriza a ejecutar de nuevo la operación en segundo plano.
Si el producto debe continuar cuando se cierra la pestaña, no ligues el trabajo a la conexión HTTP. Persiste un run durable, devuelve un identificador y permite que otra conexión consulte su estado. Cancelar una respuesta interactiva y ejecutar una tarea asíncrona son contratos distintos.
Comprueba que el upstream realmente se detuvo
La prueba más débil llama a controller.abort() antes de iniciar el modelo. Eso solo demuestra que la señal ya llega abortada. Una prueba útil inicia una operación pendiente, simula el cierre de la respuesta y comprueba que el upstream recibió el abort antes de que empiece otra etapa.
function pendingRequest(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
const stop = () => reject(signal.reason ?? new Error("aborted"));
if (signal.aborted) {
stop();
return;
}
signal.addEventListener("abort", stop, { once: true });
});
}
const controller = new AbortController();
const upstream = pendingRequest(controller.signal);
controller.abort(new Error("client_disconnected"));
await expect(upstream).rejects.toThrow("client_disconnected");
En la suite del endpoint, cubre también estos casos:
- una respuesta completada llama a
cleanupy no aborta el siguiente trabajo; - un cliente que se va durante el primer chunk no recibe más escrituras;
- un deadline produce
timed_out, nocancelled_by_client; - un error del proveedor sin desconexión llega al middleware de errores;
- una tool de escritura interrumpida sin confirmación queda marcada como incierta, sin retry ciego.
Añade un runId, la razón de parada, el nombre de la etapa y el estado del efecto al log. No guardes un prompt, token o argumento sensible solo para demostrar que la cancelación funcionó. La guía sobre validar tool calls en TypeScript muestra una frontera parecida: el valor de la prueba está en observar la decisión y el efecto, no la excepción por sí sola.
¿Cuándo no cancelar es la opción correcta?
No todo cierre de conexión significa que el trabajo perdió su valor. Un informe largo, una migración o una tarea iniciada por aprobación puede continuar en un worker. En ese caso, separa la conexión que entrega el progreso del run persistido. La guía sobre ejecución durable para agentes de IA cubre la decisión entre cola y workflow.
Tampoco prometas que cancelar borra el pasado. Si el LLM ya produjo una tool call y el servidor la envió, la señal puede interrumpir la espera, pero no puede retirar un mensaje de un broker ni deshacer un cambio confirmado. Para esos casos, usa idempotencia, consultas de estado y compensación explícita.
En flujos largos de agentes, uso RemoteCode como herramienta del autor para mantener la continuidad entre sesiones. No sustituye el signal de cancelación, el deadline ni la prueba de que una operación externa terminó.
Conclusión
El camino es corto, pero cada enlace importa: detectar la desconexión, abortar la señal, pasarla al stream del LLM, detener nuevas escrituras, limpiar listeners y registrar la razón. Si un enlace ignora la señal, el servidor puede seguir trabajando después de que el usuario se haya ido.
Empieza con un adapter aislado y una fixture que permanezca pendiente hasta que la señal aborte. Después prueba el endpoint detrás del proxy que realmente utilizas. El resultado importante es que el runtime pueda demostrar que la interfaz dejó de mostrar tokens, que no inició otra etapa y que trató cualquier efecto externo incierto como incierto.
Fuentes consultadas
- Node.js, “HTTP”, consultado el 12 de noviembre de 2026, https://nodejs.org/api/http.html
- Node.js, “Global objects: AbortController and AbortSignal”, consultado el 12 de noviembre de 2026, https://nodejs.org/api/globals.html
- OpenAI, “openai-node”, consultado el 12 de noviembre de 2026, https://github.com/openai/openai-node
- OpenAI, “Stream to client with Express”, consultado el 12 de noviembre de 2026, https://github.com/openai/openai-node/blob/main/examples/chat-completions/stream-to-client-express.ts