A loading spinner tells you nothing. When an AI task stretches across minutes—or returns to the queue for a third retry—you need to see the state. Server-Sent Events give you that visibility without the handshake overhead of WebSockets or the choreography of long polling. The server keeps a single HTTP response open and pushes plain-text updates as things change. The client reads them as they arrive.

If the connection drops, you probably do not want to start over. A well-built SSE stream remembers where you were. With Node.js 20 and the standard library alone, you can wire this up. No external packages are required.

What the wire format looks like

An SSE message is simple text. The server writes three things: an optional event name, a required data field, and an id field that becomes your save point. Each record ends with two newline characters—a blank line that marks the boundary.

A healthy stream might look like this on the wire:

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

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

The browser’s EventSource client reads these lines automatically. It raises an event for each block and stores the latest id internally. If the TCP connection flakes out, the client waits, reconnects, and sends the stored identifier back to the server as the Last-Event-ID header. That header is the entire reason the pattern works. Without it, you have no durable cursor.

Wiring the server in Node.js

Node’s built-in http module can handle this directly. When a request comes in, set the correct headers so the client knows this is a stream, not a page:

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

Strip away buffering. Proxies and frameworks sometimes batch responses, which kills the real-time feel, so flush after every chunk.

Send the ID first, then the event type, then the payload data, then the terminating blank line. Order matters only in that the ID must arrive before the blank line so the client captures it. If you are using the native response.write(), the output is literally:

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

That trailing \n\n is not decorative. SSE parsers treat it as the record terminator. Miss it, and the client hangs waiting for more data.

The cursor is everything

A fresh HTTP connection does not guarantee a fresh state. When a client reconnects, the Last-Event-ID header tells you the last message they received. Your job is to resume from the next one, not from the beginning.

This means maintaining an ordered log or journal of events server-side. An in-memory array works for a demo. In production you want something durable—append to a database log, a Redis stream, or a write-ahead journal—because a server restart should not wipe the history and force every client to start from zero.

index your events by a monotonically increasing integer or a ULID. When reconnect comes in, query for events where id > lastEventId, and replay them in order. Insert a small artificial delay or batch if you have hundreds of backlogged messages, but send them oldest-first so the client can rebuild state chronologically.

Expect duplicates

Networks are not reliable. A server might send an event, lose the TCP acknowledgment, and send it again after a timeout. Design for at-least-once delivery from the start.

On the client, deduplication is cheap. Keep a Map keyed by event ID. When a new event arrives, check the map. If the ID exists, drop the duplicate silently. Because your server assigns deterministic IDs, this makes duplicates harmless. The map does not need to grow forever. Once you confirm an event is safely processed, evict older IDs. A sliding window of a few hundred entries is usually enough for browser clients.

When the cursor expires

Eventually a client will reconnect after hours or days. If your history buffer only covers the last thousand events and the client is two thousand behind, replaying gaps is impossible.

Do not stream a partial history. That leaves the client in an inconsistent state. Instead, detect an expired cursor and send a full snapshot as the next event. The snapshot should carry a new cursor that anchors the client to the current state. From there, live deltas resume as normal. Document this boundary clearly in your protocol so client code knows when to reset its local model rather than append.

Protect the stream

Open SSE endpoints are attractive targets. Anyone can hold a connection, and replay requests can amplify read load on your storage.

Proteja o endpoint com a autorização adequada. Como o EventSource do navegador não suporta cabeçalhos personalizados, passe o token na query string ou use cookies com políticas SameSite restritas. Valide o token antes de alocar os recursos do stream.

Defina limites de histórico e cotas por usuário. Limite o número de eventos armazenados por tarefa e o número de conexões simultâneas por cliente. Registre desconexões e replays para que você possa identificar um cliente malicioso sobrecarregando seu endpoint de cursor.

O padrão se expande

Esta abordagem não está limitada ao HTTP. As mesmas regras se aplicam quando você migra para WebSockets, filas de mensagens ou interfaces agente-a-agente. O transporte muda — você pode usar frames binários ou assinaturas de tópicos — mas o problema subjacente permanece idêntico. Você precisa de um cursor, um log durável, semântica de "pelo menos uma vez" (at-least-once), deduplicação no cliente e um fallback para snapshots completos quando o cursor expirar. Resolva a convergência de estado uma única vez e você poderá enviá-la via TCP, WebSocket ou um broker como o RabbitMQ sem precisar redesenhar a lógica central.

Mantenha a simplicidade

Server-Sent Events funcionam porque rodam sobre o HTTP comum. Proxies os entendem. Load balancers podem realizar health-checks neles. A depuração é tão fácil quanto usar curl. Mas essa simplicidade desaparece se você ignorar os casos de borda. Construa o cursor. Espere replays. Faça a deduplicação no cliente. Faça um snapshot quando o histórico acabar. Faça isso, e suas tarefas de IA de longa duração reportarão seu progresso de forma honesta, mesmo com Wi-Fi instável, reinicializações de servidor e a ocasional suspensão do navegador durante a noite.

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

Participe da discussão: GyaanSetu AI Community