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.
Gate the endpoint with proper authorization. Because the browser EventSource does not support custom headers, pass the token in the query string or use cookies with strict SameSite policies. Validate the token before you allocate stream resources.
Set history limits and per-user quotas. Cap the number of stored events per task, and cap the number of concurrent connections per client. Log disconnects and replays so you can spot a rogue client hammering your cursor endpoint.
The pattern travels
This approach is not trapped inside HTTP. The same rules apply when you move to WebSockets, message queues, or agent-to-agent interfaces. The transport changes—you might use binary frames or topic subscriptions—but the underlying problem stays identical. You need a cursor, a durable log, at-least-once semantics, client deduplication, and a fallback to full snapshots when the cursor goes stale. Solve state convergence once, and you can ship it over TCP, WebSocket, or a broker like RabbitMQ without redesigning the core logic.
Keep it simple
Server-Sent Events work because they ride on ordinary HTTP. Proxies understand them. Load balancers can health-check them. Debugging is as easy as curl. But that simplicity disappears if you ignore the edge cases. Build the cursor. Expect replays. Deduplicate on the client. snapshot when history runs out. Do that, and your long-running AI tasks will report their progress honestly, even through spotty Wi-Fi, server restarts, and the occasional overnight browser sleep.
Source: Build a Reconnecting SSE Task Stream with Node.js
Join the discussion: GyaanSetu AI Community
