Ein Lade-Spinner sagt dir nichts aus. Wenn eine KI-Aufgabe sich über Minuten hinzieht – oder zum dritten Mal in die Warteschlange zurückkehrt – musst du den Status sehen können. Server-Sent Events bieten diese Sichtbarkeit ohne den Handshake-Overhead von WebSockets oder die Choreografie von Long Polling. Der Server hält eine einzige HTTP-Response offen und pusht Plain-Text-Updates, sobald sich etwas ändert. Der Client liest diese beim Eintreffen aus.

Wenn die Verbindung abbricht, möchtest du wahrscheinlich nicht von vorne beginnen. Ein gut gebauter SSE-Stream merkt sich, wo du warst. Mit Node.js 20 und allein der Standardbibliothek lässt sich dies umsetzen. Es sind keine externen Pakete erforderlich.

So sieht das Wire-Format aus

Eine SSE-Nachricht ist einfacher Text. Der Server schreibt drei Dinge: einen optionalen Event-Namen, ein erforderliches data-Feld und ein id-Feld, das als dein Speicherpunkt dient. Jeder Datensatz endet mit zwei Zeilenumbruch-Zeichen – einer Leerzeile, die die Grenze markiert.

Ein funktionierender Stream könnte auf der Leitung so aussehen:

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

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

Der EventSource-Client des Browsers liest diese Zeilen automatisch. Er löst für jeden Block ein Event aus und speichert die neueste id intern. Wenn die TCP-Verbindung abbricht, wartet der Client, verbindet sich neu und sendet die gespeicherte Kennung als Last-Event-ID-Header an den Server zurück. Dieser Header ist der einzige Grund, warum dieses Pattern funktioniert. Ohne ihn hast du keinen dauerhaften Cursor.

Den Server in Node.js implementieren

Das integrierte http-Modul von Node kann dies direkt handhaben. Wenn eine Anfrage eingeht, setze die korrekten Header, damit der Client weiß, dass es sich um einen Stream und nicht um eine Seite handelt:

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

Deaktiviere das Buffering. Proxies und Frameworks bündeln Antworten manchmal, was das Echtzeit-Gefühl zerstört; daher solltest du nach jedem Chunk flushen.

Sende zuerst die ID, dann den Event-Typ, dann die Payload-Daten und schließlich die abschließende Leerzeile. Die Reihenfolge ist nur deshalb wichtig, weil die ID vor der Leerzeile ankommen muss, damit der Client sie erfassen kann. Wenn du das native response.write() verwendest, sieht die Ausgabe buchstäblich so aus:

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

Dieses abschließende \n\n ist nicht dekorativ. SSE-Parser behandeln es als Datensatz-Terminator. Wenn du es vergisst, bleibt der Client hängen und wartet auf weitere Daten.

Der Cursor ist alles

Eine neue HTTP-Verbindung garantiert keinen neuen Zustand. Wenn sich ein Client neu verbindet, teilt dir der Last-Event-ID-Header die letzte empfangene Nachricht mit. Deine Aufgabe ist es, mit der nächsten Nachricht fortzufahren, nicht von vorne zu beginnen.

Das bedeutet, serverseitig ein geordnetes Log oder Journal von Events zu führen. Ein In-Memory-Array reicht für eine Demo aus. In der Produktion möchtest du etwas Dauerhaftes – hänge die Daten an ein Datenbank-Log, einen Redis-Stream oder ein Write-Ahead-Journal an –, denn ein Server-Neustart sollte die Historie nicht löschen und jeden Client zwingen, bei Null anzufangen.

Indiziere deine Events mit einer monoton steigenden Ganzzahl oder einer ULID. Wenn eine Wiederverbindung erfolgt, frage nach Events, bei denen id > lastEventId gilt, und spiele sie der Reihe nach ab. Füge eine kleine künstliche Verzögerung oder Batching ein, wenn du hunderte von rückständigen Nachrichten hast, aber sende sie in der Reihenfolge „das älteste zuerst“, damit der Client den Zustand chronologisch wiederherstellen kann.

Erwarte Duplikate

Netzwerke sind nicht zuverlässig. Ein Server sendet eventuell ein Event, verliert die TCP-Bestätigung und sendet es nach einem Timeout erneut. Plane von Anfang an für eine „At-Least-Once“-Zustellung.

Auf dem Client ist die Deduplizierung kostengünstig. Behalte eine Map, die nach Event-ID geschlüsselt ist. Wenn ein neues Event eintrifft, prüfe die Map. Wenn die ID existiert, verwerfe das Duplikat stillschweigend. Da dein Server deterministische IDs vergibt, sind Duplikate dadurch harmlos. Die Map muss nicht ewig wachsen. Sobald du bestätigst, dass ein Event sicher verarbeitet wurde, entferne ältere IDs. Ein Sliding Window von ein paar hundert Einträgen reicht für Browser-Clients normalerweise aus.

Wenn der Cursor abläuft

Irgendwann wird sich ein Client nach Stunden oder Tagen wieder verbinden. Wenn dein Historien-Buffer nur die letzten tausend Events abdeckt und der Client zwei tausend Nachrichten hinterherhinkt, ist das Nachspielen der Lücken unmöglich.

Streame keine unvollständige Historie. Das versetzt den Client in einen inkonsistenten Zustand. Erkenne stattdessen einen abgelaufenen Cursor und sende einen vollständigen Snapshot als nächstes Event. Der Snapshot sollte einen neuen Cursor enthalten, der den Client am aktuellen Zustand verankert. Von dort aus können Live-Deltas wieder normal fortgesetzt werden. Dokumentiere diese Grenze klar in deinem Protokoll, damit der Client-Code weiß, wann er sein lokales Modell zurücksetzen muss, anstatt Daten anzuhängen.

Schütze den Stream

Offene SSE-Endpunkte sind attraktive Ziele. Jeder kann eine Verbindung offen halten, und Replay-Anfragen können die Leselast auf deinem Speicher verstärken.

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