Un indicador de carga no te dice nada. Cuando una tarea de IA se extiende durante minutos —o vuelve a la cola para un tercer reintento— necesitas ver el estado. Server-Sent Events te ofrece esa visibilidad sin la sobrecarga del handshake de WebSockets ni la coreografía del long polling. El servidor mantiene una única respuesta HTTP abierta y envía actualizaciones en texto plano a medida que las cosas cambian. El cliente las lee conforme llegan.

Si la conexión se cae, probablemente no quieras empezar de cero. Un flujo SSE bien construido recuerda dónde te quedaste. Solo con Node.js 20 y la biblioteca estándar, puedes implementar esto. No se requieren paquetes externos.

Cómo es el formato de transmisión

Un mensaje SSE es texto simple. El servidor escribe tres cosas: un nombre de evento opcional, un campo data obligatorio y un campo id que se convierte en tu punto de guardado. Cada registro termina con dos caracteres de nueva línea: una línea en blanco que marca el límite.

Un flujo saludable podría verse así en la transmisión:

id: 14
event: status
data: {"phase":"testing","progress":43}

id: 15
event: status
data: {"phase":"retrying","attempt":2}

El cliente EventSource del navegador lee estas líneas automáticamente. Genera un evento para cada bloque y almacena el último id internamente. Si la conexión TCP falla, el cliente espera, se reconecta y envía el identificador almacenado al servidor mediante el encabezado Last-Event-ID. Ese encabezado es la razón principal por la que este patrón funciona. Sin él, no tienes un cursor duradero.

Conectando el servidor en Node.js

El módulo http integrado de Node puede manejar esto directamente. Cuando llega una solicitud, establece los encabezados correctos para que el cliente sepa que esto es un flujo y no una página:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Elimina el buffering. Los proxies y los frameworks a veces agrupan las respuestas, lo que mata la sensación de tiempo real, así que realiza un flush después de cada fragmento (chunk).

Envía primero el ID, luego el tipo de evento, luego los datos de la carga útil (payload) y, finalmente, la línea en blanco de terminación. El orden solo importa en que el ID debe llegar antes de la línea en blanco para que el cliente pueda capturarlo. Si estás usando el response.write() nativo, la salida es literalmente:

response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);

Ese \n\n final no es decorativo. Los analizadores de SSE lo tratan como el terminador del registro. Si lo pasas por alto, el cliente se quedará esperando más datos.

El cursor lo es todo

Una conexión HTTP nueva no garantiza un estado nuevo. Cuando un cliente se reconecta, el encabezado Last-Event-ID te indica el último mensaje que recibió. Tu trabajo es reanudar desde el siguiente, no desde el principio.

Esto significa mantener un registro o diario ordenado de eventos en el lado del servidor. Un array en memoria funciona para una demostración. En producción, querrás algo duradero —añade a un log de base de datos, un stream de Redis o un write-ahead journal— porque un reinicio del servidor no debería borrar el historial y obligar a cada cliente a empezar desde cero.

Indexa tus eventos mediante un entero monótonamente creciente o un ULID. Cuando llegue una reconexión, consulta los eventos donde id > lastEventId y reprodúcelos en orden. Inserta un pequeño retraso artificial o agrupa los mensajes si tienes cientos de mensajes acumulados, pero envíalos del más antiguo al más reciente para que el cliente pueda reconstruir el estado cronológicamente.

Espera duplicados

Las redes no son fiables. Un servidor podría enviar un evento, perder el acuse de recibo de TCP y enviarlo de nuevo tras un tiempo de espera. Diseña para una entrega de al menos una vez (at-least-once delivery) desde el principio.

En el cliente, la deduplicación es barata. Mantén un Map con la clave del ID del evento. Cuando llegue un nuevo evento, comprueba el mapa. Si el ID existe, descarta el duplicado silenciosamente. Debido a que tu servidor asigna IDs deterministas, esto hace que los duplicados sean inofensivos. El mapa no necesita crecer para siempre. Una vez que confirmes que un evento se ha procesado de forma segura, elimina los IDs más antiguos. Una ventana deslizante de unos pocos cientos de entradas suele ser suficiente para los clientes de navegador.

Cuando el cursor expira

Eventualmente, un cliente se reconectará después de horas o días. Si tu búfer de historial solo cubre los últimos mil eventos y el cliente lleva dos mil de retraso, reponer los huecos es imposible.

No transmitas un historial parcial. Eso deja al cliente en un estado inconsistente. En su lugar, detecta un cursor expirado y envía una instantánea (snapshot) completa como el siguiente evento. La instantánea debe incluir un nuevo cursor que ancle al cliente al estado actual. A partir de ahí, los deltas en vivo se reanudan con normalidad. Documenta este límite claramente en tu protocolo para que el código del cliente sepa cuándo reiniciar su modelo local en lugar de simplemente añadir datos.

Protege el flujo

Los endpoints de SSE abiertos son objetivos atractivos. Cualquiera puede mantener una conexión abierta, y las solicitudes de repetición pueden amplificar la carga de lectura en tu almacenamiento.

Proteja el endpoint con una autorización adecuada. Debido a que el EventSource del navegador no admite encabezados personalizados, pase el token en la cadena de consulta (query string) o utilice cookies con políticas SameSite estrictas. Valide el token antes de asignar los recursos del flujo.

Establezca límites de historial y cuotas por usuario. Limite el número de eventos almacenados por tarea y el número de conexiones concurrentes por cliente. Registre las desconexiones y las repeticiones para que pueda detectar un cliente malicioso que esté saturando su endpoint de cursor.

El patrón trasciende

Este enfoque no se limita al HTTP. Las mismas reglas se aplican cuando se pasa a WebSockets, colas de mensajes o interfaces agente-a-agente. El transporte cambia —podría usar tramas binarias o suscripciones a temas—, pero el problema subyacente sigue siendo el mismo. Necesita un cursor, un registro (log) duradero, semántica de al menos una vez (at-least-once), deduplicación en el cliente y una alternativa de instantáneas (snapshots) completas cuando el cursor caduque. Resuelva la convergencia de estado una vez y podrá enviarla a través de TCP, WebSocket o un bróker como RabbitMQ sin necesidad de rediseñar la lógica central.

Manténgalo simple

Los Server-Sent Events funcionan porque se basan en el HTTP ordinario. Los proxies los entienden. Los equilibradores de carga pueden realizar comprobaciones de estado (health-checks). La depuración es tan fácil como usar curl. Pero esa simplicidad desaparece si ignora los casos límite. Construya el cursor. Espere repeticiones. Deduplique en el cliente. Cree una instantánea cuando el historial se agote. Haga eso, y sus tareas de IA de larga duración informarán su progreso con precisión, incluso con un Wi-Fi inestable, reinicios del servidor y el ocasional modo de suspensión del navegador durante la noche.

Fuente: Build a Reconnecting SSE Task Stream with Node.js

Únase a la discusión: GyaanSetu AI Community