Construyes una aplicación de chat con IA.

Un usuario envía:

“Explícame cómo funcionan los sistemas distribuidos.”

Tu servidor llama a la API del LLM y comienza a transmitir la respuesta:

LLM
 │
 ├── "Distribuido"
 ├── "sistemas"
 ├── "son"
 ├── ...
 │
 ▼
Navegador

Todo se ve genial.

Entonces el usuario cierra la pestaña del navegador.

La respuesta desaparece.

¿Pero qué pasa con la petición al LLM?

¿Sigue corriendo?

Si tu servidor no propaga la cancelación explícitamente, la respuesta puede ser sí.

Y eso no es solo un problema de correctitud. Para una aplicación de IA, puede convertirse en un problema de costos.

Un usuario puede abandonar una generación después de 500 tokens, mientras tu backend sigue pagando por los miles de tokens restantes.

En este artículo construiremos el camino de cancelación para un servidor LLM en TypeScript:

Navegador
   │
   │ desconexión del cliente
   ▼
Hono / Node.js
   │
   │ AbortSignal
   ▼
fetch()
   │
   │ cancelación
   ▼
API del LLM

En el camino veremos:

  • Por qué cerrar una pestaña no cancela automáticamente tu petición al LLM
  • Cómo funcionan AbortController y AbortSignal
  • Cómo combinar desconexiones del cliente con timeouts del lado del servidor
  • Cómo interactúa la cancelación con respuestas en streaming
  • Cuándo no deberías cancelar una petición
  • Por qué esto es fundamentalmente un problema de TypeScript del lado del servidor

El problema: el usuario se fue, pero tu LLM no

Considera el servidor más simple posible:

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',
    },
  });
});

Esto funciona.

El navegador envía una petición:

Navegador
   │
   │ POST /api/chat
   ▼
Servidor
   │
   │ fetch()
   ▼
API del LLM

El LLM empieza a generar tokens y el servidor los transmite de vuelta al navegador.

Pero ahora:

Navegador
   │
   X
   │
   │ pestaña cerrada

El navegador ya no está.

¿Qué pasa con la llamada fetch() al LLM?

Nada le dice automáticamente a tu aplicación que la cancele.

El servidor y la petición al LLM son operaciones separadas.

Tu servidor necesita conectar sus ciclos de vida explícitamente.


Primero: entiende las dos conexiones

Aquí hay en realidad dos conexiones HTTP.

       Conexión #1
Navegador ────────────────► Servidor
                             │
                             │ Conexión #2
                             ▼
                         API del LLM

El navegador controla la Conexión #1.

Tu servidor controla la Conexión #2.

Cuando el navegador cierra la pestaña:

Navegador                  Servidor                  API del LLM
   │                        │                       │
   │──── HTTP request ─────►│──── HTTP request ───►│
   │                        │                       │
   X                        │                       │
   │                        │                       │
   │ conexión cerrada       │                       │
   │                        │                       │
                            │                       │
                            │──── sigue conectado ─►│

La API del LLM no sabe mágicamente que el navegador desapareció.

Tu servidor tiene que propagar la cancelación:

Navegador
   X
   │
   ▼
Servidor
   │
   │ abort()
   ▼
API del LLM

Aquí es donde entra AbortController.


AbortController: la primitiva de cancelación

La plataforma Web ya nos da un mecanismo estándar de cancelación:

const controller = new AbortController();

const response = await fetch(url, {
  signal: controller.signal,
});

// Más tarde:
controller.abort();

La parte importante es:

signal: controller.signal

La AbortSignal se pasa a la operación.

Cuando:

controller.abort();

se ejecuta, las APIs que soportan la señal pueden terminar la operación.

Node.js soporta AbortSignal en todas sus APIs asíncronas, incluyendo streams y operaciones relacionadas con HTTP.

Esto nos da un modelo mental útil:

AbortController
       │
       │ signal
       ▼
 ┌─────────────┐
 │ trabajo async │
 └─────────────┘
       │
       │ abort()
       ▼
   cancelado

El controller no necesita saber qué está cancelando.

Simplemente transmite:

“Detente.”

Cualquier operación que haya recibido su señal puede reaccionar en consecuencia.


Detectando una desconexión del cliente

Ahora necesitamos responder la primera pregunta:

¿Cómo sabe el servidor que el navegador se desconectó?

Con Hono, la petición expone la señal subyacente:

c.req.raw.signal

Esta señal se aborta cuando se termina la conexión del cliente.

Así que podemos conectarla directamente a la petición del LLM:

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',
    },
  });
});

Ahora el ciclo de vida se ve así:

Navegador
   │
   │ request
   ▼
Hono
   │
   │ c.req.raw.signal
   ▼
fetch()
   │
   ▼
API del LLM

Si el navegador se desconecta:

Navegador
   X
   │
   ▼
c.req.raw.signal
   │
   │ aborted
   ▼
fetch()
   │
   │ cancelado
   ▼
API del LLM

Esta es la conexión crítica que muchas primeras versiones de aplicaciones de IA pasan por alto.


Pero la desconexión del cliente no es la única razón para cancelar

Hay otro modo de fallo.

¿Qué pasa si la API del LLM simplemente tarda demasiado?

No quieres una petición colgada para siempre.

Así que tenemos dos condiciones de cancelación independientes:

             ┌─────────────────┐
             │ El cliente cierra│
             │ el navegador    │
             └────────┬────────┘
                      │
                      ▼
                   CANCELAR
                      ▲
                      │
             ┌────────┴────────┐
             │ Timeout servidor│
             │ 30 segundos    │
             └─────────────────┘

Queremos:

Cancelar si cualquiera de las condiciones ocurre.

JavaScript moderno nos da exactamente eso:

AbortSignal.any()

AbortSignal.any() crea una señal que se aborta cuando cualquiera de las señales suministradas se aborta. Está disponible en Node.js 20+ y versiones posteriores.

Así que:

const timeoutSignal = AbortSignal.timeout(30_000);

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

Ahora una sola señal representa ambas condiciones.


La versión completa

Juntando todo:

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;
  }
});

Ahora hay dos formas en que la petición puede terminar:

                  ┌── el navegador se cierra
                  │
                  │
AbortSignal.any ──┤
                  │
                  │
                  └── timeout 30s
                         │
                         ▼
                       abort
                         │
                         ▼
                     fetch()
                         │
                         ▼
                      API del LLM

Esto es mucho mejor que mantener timers y handlers de desconexión separados manualmente.


Por qué esto importa aún más con streaming

La cancelación se vuelve particularmente importante cuando estás transmitiendo la salida del LLM.

Sin streaming:

Navegador ── request ──► Servidor ──► LLM

                         20 segundos

Navegador ◄────────────── respuesta completa

Con streaming:

Navegador ◄── token ── token ── token ── token ── ...

La petición puede mantenerse viva durante decenas de segundos o incluso minutos.

Eso crea una ventana de cancelación mucho más grande.

La arquitectura es esencialmente un pipeline de streams:

API del LLM
   │
   │ chunks
   ▼
Parser SSE
   │
   │ eventos
   ▼
TransformStream
   │
   │ eventos
   ▼
Respuesta HTTP
   │
   ▼
Navegador

Lo importante es que el stream no es un mecanismo especial de “IA”.

Es solo un pipeline de procesamiento de streams.

Node.js y la Web Streams API proporcionan mecanismos de cancelación a través de AbortSignal, y las operaciones de stream pueden terminarse cuando su señal se aborta.

Por eso entender los streams del lado del servidor es tan útil al construir aplicaciones de IA.


Los chunks no son mensajes

Hay otro problema sutil con el streaming.

Supón que el LLM envía:

data: Hola\n\n

data: mundo\n\n

Podrías imaginar que tu cliente HTTP recibe exactamente esos chunks.

No tiene por qué ser así.

La red podría darte:

data: Hol

luego:

a\n\ndata: mun

luego:

do\n\n

Un chunk TCP no es necesariamente un mensaje a nivel de aplicación.

Así que tu pipeline de streaming necesita almacenar en buffer los datos incompletos:

Chunks de red
      │
      ▼
┌──────────────┐
│    Buffer    │
└──────┬───────┘
       │
       ▼
Eventos SSE completos
       │
       ▼
Aplicación

Un TransformStream encaja de forma natural:

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

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

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

      // El último fragmento puede estar incompleto.
      buffer = lines.pop() ?? '';

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

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

El patrón es:

upstream
   ↓
chunks
   ↓
buffer
   ↓
mensajes completos
   ↓
lógica de negocio

Este mismo patrón aparece al procesar archivos grandes, respuestas SSE y respuestas de streaming de LLMs. La abstracción clave es el procesamiento incremental, no la IA.


¿Qué pasa con la contrapresión (backpressure)?

Hay una razón más para tratar esto como un pipeline de streams.

¿Qué pasa si el productor es más rápido que el consumidor?

LLM
 │
 │ muy rápido
 ▼
TransformStream
 │
 │ muy rápido
 ▼
Red
 │
 │ lenta
 ▼
Navegador

Si los datos se acumularan indefinidamente, el uso de memoria podría crecer.

Los streams resuelven esto con contrapresión.

Conceptualmente:

LLM
 │
 ▼
Transform
 │
 ▼
Red
 │
 ▼
Navegador
 ▲
 │
 └──── backpressure

Cuando el consumidor downstream no puede seguir el ritmo, la maquinaria del stream puede dejar de empujar datos hacia arriba hasta que haya capacidad disponible.

Esta es una de las principales razones por las que los streams son preferibles a acumular toda la respuesta en memoria.

Y es la misma razón por la que estos dos enfoques son fundamentalmente diferentes:

// Esperar todo
const result = await response.text();

versus:

// Procesar incrementalmente
for await (const chunk of stream) {
  process(chunk);
}

Para aplicaciones de IA, el procesamiento incremental es lo que hace posibles las respuestas token por token.


El problema del costo

Ahora volvamos a nuestra pregunta original.

Supón:

El usuario inicia la generación
        │
        ▼
El LLM genera 5,000 tokens
        │
        │
        ├── El usuario cierra la pestaña tras 500 tokens
        │
        ▼
El servidor continúa generando

El impacto financiero exacto depende del modelo, el proveedor, la petición, el caché y el modelo de facturación.

Pero el principio de ingeniería es simple:

Si una operación ya no tiene consumidor, debes decidir explícitamente si la operación debe continuar.

Para una respuesta de chat interactiva, continuar suele ser un desperdicio.

El usuario no va a leer tokens que no tienen a dónde ir.

La cancelación te da una forma de liberar el trabajo:

500 tokens generados
        │
        ▼
desconexión del cliente
        │
        ▼
AbortSignal
        │
        ▼
cancelar petición upstream

El beneficio no es solo el costo de tokens.

También liberas:

  • una conexión HTTP
  • buffers de stream
  • recursos del lado del servidor
  • capacidad de concurrencia
  • trabajo de generación del lado del proveedor, cuando la API upstream honra la cancelación

Pero no cancele todo

Aquí está la distinción arquitectónica importante.

La desconexión del cliente no siempre significa “cancela la tarea”.

Considera cuatro operaciones.

1. Chat interactivo

El usuario hace una pregunta
        ↓
El LLM genera la respuesta
        ↓
El usuario cierra la pestaña

Cancélalo.

El resultado no tiene valor si el usuario lo abandonó.


2. Indexación de archivos

El usuario sube un PDF
        ↓
El servidor comienza a indexar
        ↓
El usuario cierra el navegador

No necesariamente lo canceles.

La operación de indexación es parte de un workflow persistente.

El navegador del usuario solo está observando la operación.


3. Escritura en base de datos

El usuario envía un formulario
        ↓
El servidor escribe en la base de datos
        ↓
El navegador se desconecta

Usualmente, quieres que la operación de base de datos se complete.

La escritura en la base de datos es una operación de negocio, no una respuesta en streaming.


4. Agente de larga duración

Considera una ejecución de Agente:

Usuario
 │
 ▼
POST /agent/run
 │
 ▼
Agente
 │
 ├── búsqueda
 ├── navegación
 ├── llamadas a tools
 ├── generación
 └── ...

Esto puede tomar varios minutos.

Vincular todo el ciclo de vida del Agente a una conexión HTTP suele ser la arquitectura incorrecta.

En su lugar:

POST /agent/run
        │
        ▼
      taskId
        │
        ▼
worker en segundo plano
        │
        ├── llamadas a tools
        ├── llamadas al LLM
        └── estado

El frontend puede entonces suscribirse a la tarea:

Navegador ───────► estado de la tarea
       ◄──────── SSE / WebSocket / polling

Ahora cerrar el navegador no destruye necesariamente la ejecución del Agente.

Esta distinción es importante:

La cancelación es una decisión de negocio, no meramente una decisión técnica.


Un mejor modelo mental

En lugar de pensar:

“El navegador se desconectó, así que cancela todo.”

Piensa:

“¿Cuál es el ciclo de vida de esta operación?”

Hay dos tipos fundamentalmente diferentes de trabajo:

                  Operación
                      │
          ┌───────────┴───────────┐
          │                       │
     Ligada a conexión      Ligada a tarea
          │                       │
          ▼                       ▼
    Chat en streaming       Ejecución de agente
    autocompletado          indexación de archivos
    respuesta en vivo       escritura en BD
          │                       │
          ▼                       ▼
    desconexión → cancelar   desconexión → continuar

Esta distinción se vuelve cada vez más importante a medida que una aplicación de IA crece.


Un problema más: los errores

La cancelación no es necesariamente un error normal de aplicación.

Por ejemplo:

try {
  await fetch(url, { signal });
} catch (error) {
  if (signal.aborted) {
    // Cancelación esperada
    return;
  }

  throw error;
}

Compara eso con:

429 Rate Limit
502 Falla del upstream
500 Error interno
AbortError

Estos representan cosas diferentes.

Un servidor de producción debería distinguir:

  • fallas operativas esperadas
  • fallas de servicios externos
  • errores de programador
  • cancelación intencional

Una jerarquía de errores útil podría verse así:

AppError
├── ValidationError
├── UnauthorizedError
├── RateLimitError
├── ExternalServiceError
└── ...

Entonces un manejador global de errores puede convertir fallas conocidas de la aplicación en respuestas API consistentes, mientras que los errores inesperados de programador se registran por separado.

Este tipo de manejo centralizado de errores es especialmente importante en servidores porque un error no manejado puede afectar a muchos usuarios, no solo a una pestaña del navegador.


La arquitectura final

Juntando todo:

                         Navegador
                            │
                            │ POST /chat
                            ▼
                    ┌───────────────┐
                    │     Hono      │
                    │               │
                    │ Zod validate  │
                    └───────┬───────┘
                            │
                            ▼
                  ┌───────────────────┐
                  │   AbortSignal     │
                  │                   │
                  │ desconexión cliente│
                  │        OR         │
                  │ timeout 30s       │
                  └─────────┬─────────┘
                            │
                            ▼
                     ┌────────────┐
                     │  fetch()   │
                     └─────┬──────┘
                           │
                           │ streaming
                           ▼
                     ┌────────────┐
                     │  API LLM   │
                     └─────┬──────┘
                           │
                           │ chunks
                           ▼
                    ┌────────────────┐
                    │ TransformStream│
                    │                │
                    │ parse / buffer │
                    └───────┬────────┘
                            │
                            │ SSE
                            ▼
                         Navegador

Hay varias piezas independientes aquí:

Seguridad de tipos

Zod → input validado → ruta tipada

Streaming

LLM → chunks → TransformStream → SSE

Cancelación

desconexión ─┐
            ├─→ AbortSignal → fetch()
timeout ────┘

Manejo de errores

Errores del LLM / de la aplicación
          ↓
     jerarquía de errores
          ↓
    manejador global
          ↓
    API consistente

Ninguno de estos mecanismos es específicamente un “framework de IA”.

Son primitivas de ingeniería del lado del servidor.

Y es exactamente por eso que importan.


La lección más grande

Cuando construyes por primera vez una aplicación LLM, es tentador pensar primero en el modelo:

¿Qué modelo?
¿Qué prompt?
¿Qué framework de agentes?
¿Qué base de datos vectorial?

Pero una vez que la aplicación tiene usuarios reales, muchos de los problemas costosos son mucho menos glamorosos:

¿Qué pasa cuando el usuario se desconecta?

¿Qué pasa cuando el modelo tarda 2 minutos?

¿Qué pasa cuando el proveedor devuelve 429?

¿Qué pasa cuando el navegador no puede consumir el stream lo suficientemente rápido?

¿Qué pasa cuando la petición expira?

¿Qué pasa cuando un Agente sobrevive a la conexión HTTP?

Estos son problemas de ingeniería de servidores.

Y TypeScript te da primitivas excelentes para resolverlos:

Promises
Streams
TransformStream
AbortController
AbortSignal
async iterators
errores tipados
Zod
Hono

Una vez que entiendes estas primitivas, un servidor de streaming LLM deja de parecer una infraestructura misteriosa de IA.

Es solo un pipeline asíncrono cuidadosamente diseñado.

Y ese es el cambio importante:

La ingeniería de IA sigue siendo ingeniería de software.

El modelo puede ser probabilístico.

El servidor no debería serlo.


Para seguir leyendo

Si quieres profundizar en las primitivas subyacentes, la documentación de Node.js cubre AbortSignal, cancelación de streams y Web Streams en detalle.

Este artículo también se basa en los patrones de TypeScript del lado del servidor cubiertos en el Capítulo 3 de AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents en Leanpub, particularmente las secciones sobre Streams, cancelación y diseño de APIs con seguridad de tipos. El capítulo trata explícitamente el streaming de LLMs como una instancia del mismo modelo de streaming usado en el resto de Node.js, en lugar de una abstracción separada y específica de IA.

Si estás construyendo aplicaciones de IA con TypeScript, estos son los cimientos que vale la pena entender antes de añadir frameworks de agentes más sofisticados.


¿Te sirvió esta guía? Sigue explorando estos temas en el blog de DojoFullStack: cancelación, streaming y arquitectura de aplicaciones de IA son parte del día a día de los desarrolladores que automatizan con agentes de código.