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.

Zabezpiecz punkt końcowy odpowiednią autoryzacją. Ponieważ przeglądarkowy EventSource nie obsługuje niestandardowych nagłówków, przekaż token w ciągu zapytania (query string) lub użyj ciasteczek z rygorystycznymi politykami SameSite. Waliduj token przed przydzieleniem zasobów strumienia.

Ustaw limity historii i limity na użytkownika. Ogranicz liczbę przechowywanych zdarzeń na zadanie oraz liczbę jednoczesnych połączeń na klienta. Loguj rozłączenia i powtórzenia, aby móc wykryć nieuczciwego klienta bombardującego Twój punkt końcowy kursora.

Ten wzorzec jest przenośny

To podejście nie jest ograniczone tylko do HTTP. Te same zasady obowiązują przy przejściu na WebSockets, kolejki wiadomości czy interfejsy agent-to-agent. Warstwa transportowa się zmienia – możesz używać ramek binarnych lub subskrypcji tematów – ale podstawowy problem pozostaje identyczny. Potrzebujesz kursora, trwałego logu, semantyki at-least-once, deduplikacji po stronie klienta oraz mechanizmu powrotu do pełnych migawek (snapshots), gdy kursor stanie się nieaktualny. Rozwiąż problem zbieżności stanu raz, a będziesz mógł przesyłać go przez TCP, WebSocket lub brokera takiego jak RabbitMQ bez konieczności przeprojektowywania logiki rdzenia.

Zachowaj prostotę

Server-Sent Events działają, ponieważ opierają się na zwykłym protokole HTTP. Proxy je rozumieją. Load balancery mogą przeprowadzać testy sprawności (health-check). Debugowanie jest tak proste jak użycie curl. Ale ta prostota znika, jeśli zignorujesz przypadki brzegowe. Zbuduj kursor. Licz się z powtórzeniami. Wykonuj deduplikację po stronie klienta. twórz migawki, gdy historia się wyczerpie. Zrób to, a Twoje długotrwałe zadania AI będą rzetelnie raportować postęp, nawet przy niestabilnym Wi-Fi, restartach serwera czy okazjonalnym nocnym uśpieniu przeglądarki.

Źródło: Build a Reconnecting SSE Task Stream with Node.js

Dołącz do dyskusji: GyaanSetu AI Community