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.
योग्य अधिकाराने (authorization) एंडपॉइंट सुरक्षित करा. ब्राउझर EventSource कस्टम हेडर्सना सपोर्ट करत नसल्यामुळे, टोकन क्वेरी स्ट्रिंगमध्ये (query string) पास करा किंवा कडक SameSite पॉलिसीसह कुकीज वापरा. स्ट्रीम रिसोर्सेस वाटप करण्यापूर्वी टोकनची पडताळणी करा.
हिस्ट्री मर्यादा आणि प्रति-वापरकर्ता कोटा (per-user quotas) सेट करा. प्रत्येक टास्कसाठी साठवलेल्या इव्हेंट्सची संख्या मर्यादित करा आणि प्रत्येक क्लायंटसाठी एकाच वेळी असलेल्या कनेक्शन्सची (concurrent connections) संख्या मर्यादित करा. डिस्कनेक्ट्स आणि रिप्लेज लॉग करा, जेणेकरून तुम्ही तुमच्या कर्सर एंडपॉइंटवर सतत ताण देणाऱ्या (hammering) कोणत्याही संशयास्पद क्लायंटला ओळखू शकाल.
हा पॅटर्न सर्वत्र लागू होतो
ही पद्धत केवळ HTTP पुरती मर्यादित नाही. जेव्हा तुम्ही WebSockets, मेसेज क्यू (message queues) किंवा एजंट-टू-एजंट इंटरफेसकडे वळता, तेव्हा तेच नियम लागू होतात. ट्रान्सपोर्ट बदलू शकते—तुम्ही बायनरी फ्रेम्स किंवा टॉपिक सबस्क्रिप्शन्स वापरू शकता—परंतु मूळ समस्या तीच राहते. तुम्हाला कर्सर, ड्युरेबल लॉग, at-least-once semantics, क्लायंट ड्युप्लिकेशन आणि कर्सर जुना (stale) झाल्यावर पूर्ण स्नॅपशॉट्सचा पर्याय (fallback) आवश्यक असतो. स्टेट कन्वर्जन्सची (state convergence) समस्या एकदाच सोडवली की, तुम्ही मूळ लॉजिक पुन्हा डिझाइन न करता ते TCP, WebSocket किंवा RabbitMQ सारख्या ब्रोकरवर पाठवू शकता.
ते सोपे ठेवा
Server-Sent Events काम करतात कारण ते सामान्य HTTP वर आधारित आहेत. प्रॉक्सी (Proxies) त्यांना समजतात. लोड बॅलन्सर त्यांची हेल्थ-चेक करू शकतात. डीबगिंग curl इतके सोपे आहे. परंतु जर तुम्ही 'एज केसेस' (edge cases) दुर्लक्षित केल्या तर ही साधेपणा संपतो. कर्सर तयार करा. रिप्लेजची अपेक्षा ठेवा. क्लायंटवर ड्युप्लिकेशन टाळा. हिस्ट्री संपल्यावर स्नॅपशॉट घ्या. असे केल्यास, तुमचे दीर्घकाळ चालणारे AI टास्क, अस्थिर Wi-Fi, सर्व्हर रीस्टार्ट किंवा रात्री ब्राउझर स्लीप मोडमध्ये गेल्यावरही, त्यांची प्रगती अचूकपणे कळवतील.
स्रोत: Build a Reconnecting SSE Task Stream with Node.js
चर्चेत सहभागी व्हा: GyaanSetu AI Community
