Blog

HTTP/2 und Server-Sent Events in Node.js

Wie HTTP/2 viele Streams über eine Verbindung multiplext und warum Server-Sent Events oft die einfachere Antwort auf Live-Daten sind als WebSocket – mit einem lauffähigen SSE-Endpunkt in Node und den üblichen Fallstricken.

≈ 11 Min. Lesezeit

Diesen Beitrag anhören (16 Min.)

MP3 herunterladen

WebSocket ist kein Protokoll für Live-Daten. Es ist ein bidirektionaler Kanal, der Live-Daten nebenbei mit erledigt – und wer ihn aufmacht, bezahlt den Rückkanal, ob er ihn nutzt oder nicht: Reconnect-Logik von Hand, Upgrade-Support an jeder Zwischenstation, eigene Timeouts für langlebige Verbindungen, im Cluster meist noch Sticky Sessions und eine Pub/Sub-Schicht. Bei einem Aktienticker, einem Build-Fortschritt oder einer Benachrichtigungsleiste ist von diesem Kanal genau eine Richtung überhaupt im Einsatz.

Die Transportwahl ist damit eine Entwurfsentscheidung mit direkten Betriebs- und Testfolgen, keine Geschmackssache. Sie hängt an drei Achsen: der Richtung der Daten, der Betriebsumgebung und der Testbarkeit. Wer hier reflexhaft greift, zahlt später in mehr Code, mehr Infrastruktur und mehr selbstgebauter Fehlerbehandlung – nicht beim ersten Endpunkt, sondern bei jedem Feature, das danach an dem Kanal hängt. Bevor es um Server-Sent Events geht, lohnt ein kurzer Blick auf die Schicht darunter, denn HTTP hat sich in den letzten Jahren stärker verändert, als im Alltag ankommt.

Was HTTP/2 an der Grundlage ändert

Über Jahre war unser mentales Modell von HTTP denkbar einfach: eine Anfrage, eine Verbindung, eine Antwort. HTTP/1.1 hat das ein Stück weit aufgebohrt mit Keep-alive und Pipelining, aber im Kern blieb es dabei, dass eine TCP-Verbindung immer nur eine Anfrage gleichzeitig sinnvoll bedienen konnte. Der Browser hat das jahrelang mit roher Gewalt umgangen: Er öffnete pro Origin bis zu sechs parallele Verbindungen. Sechs. Wer schon einmal eine Seite mit hundert kleinen Assets gebaut hat, weiß, wie sich dieser Flaschenhals anfühlt.

HTTP/2 dreht dieses Modell um. Statt vieler Verbindungen gibt es eine einzige, und über diese eine Verbindung laufen viele voneinander unabhängige Streams parallel – das nennt sich Multiplexing. Jeder Stream trägt genau eine Request/Response, und alle teilen sich denselben TCP-Tunnel. Damit verschwindet das Head-of-Line-Blocking auf Anwendungsebene, und die künstliche Sechser-Grenze pro Origin ist kein Thema mehr. Für gleichzeitig offene Streams sehen die meisten Implementierungen 100 vor (SETTINGS_MAX_CONCURRENT_STREAMS) – so empfiehlt es die HTTP/2-Spezifikation als Untergrenze –, und der Wert lässt sich konfigurieren.

graph LR
  subgraph HTTP1["HTTP/1.1 – sechs Verbindungen"]
    C1[Client] -->|Verbindung 1| S1[Server]
    C1 -->|Verbindung 2| S1
    C1 -->|Verbindung 3| S1
    C1 -->|... bis 6| S1
  end
  subgraph HTTP2["HTTP/2 – eine Verbindung, viele Streams"]
    C2[Client] -->|Stream 1<br/>Stream 3<br/>Stream 5| S2[Server]
  end

Zwei weitere Dinge sind praktisch relevant. Zum einen komprimiert HTTP/2 Header per HPACK. Wer schon einmal mitgeschnitten hat, wie oft dieselben Cookie- und User-Agent-Header über die Leitung wandern, ahnt, wie viel Redundanz da steckt. HPACK hält dafür eine statische und eine dynamische Tabelle vor und referenziert wiederkehrende Header nur noch per Index statt sie erneut auszubuchstabieren.

Zum anderen gibt es Server Push – und hier bin ich inzwischen ausdrücklich zurückhaltend. Die Idee war charmant: Der Server schickt Ressourcen mit, von denen er weiß, dass der Client sie gleich braucht, bevor der überhaupt danach fragt. In der Praxis war das schwer richtig einzusetzen, oft hat es Bandbreite verschwendet, und Chrome hat HTTP/2 Server Push mit Version 106 im Jahr 2022 standardmäßig entfernt. Im Node-http2-Modul ist die Funktion weiterhin da, aber ich würde heute niemandem raten, eine Architektur darauf zu bauen. Wer Preloading möchte, ist mit preload-Hints und 103 Early Hints besser bedient.

Ein wichtiger Punkt für den Browserkontext: HTTP/2 sprechen Browser praktisch ausschließlich über TLS mit ALPN-Aushandlung auf h2. Unverschlüsseltes h2c gibt es im Node-Modul zwar, aber für alles, was ein Browser anfassen soll, brauchst du Zertifikate.

Das http2-Modul in Node

Node bringt HTTP/2 als eigenes Kernmodul mit, node:http2, stabil seit den frühen Node-10-Zeiten. Zum Veröffentlichungszeitpunkt dieses Beitrags waren 18, 20 und 22 die relevanten Release-Linien; im Juli 2026 sind 22 und 24 LTS, 26 ist Current. Das Modul steht ohne Flag bereit. Es gibt zwei Wege, damit zu arbeiten. Der erste ist die native Core-API rund um Streams:

import { createSecureServer } from 'node:http2';
import { readFileSync } from 'node:fs';

const server = createSecureServer({
  key: readFileSync('localhost-key.pem'),
  cert: readFileSync('localhost-cert.pem'),
});

server.on('stream', (stream, headers) => {
  const path = headers[':path'];
  stream.respond({
    ':status': 200,
    'content-type': 'text/plain; charset=utf-8',
  });
  stream.end(`you requested ${path}`);
});

server.listen(8443);

Auffällig sind die Pseudo-Header mit führendem Doppelpunkt und in Kleinbuchstaben: :status, :method, :path, :scheme, :authority. Sie sind der HTTP/2-Ersatz für die alte Statuszeile und die klassischen Request-Metadaten. Das stream-Event ist das Herzstück – für jeden eingehenden Stream bekommst du ein eigenes Duplex-Objekt.

Der zweite Weg ist die Kompatibilitäts-API. Sie bildet req/res so nach, wie man sie aus dem alten http-Modul kennt, sodass bestehender HTTP/1-Code fast unverändert weiterläuft:

import { createSecureServer } from 'node:http2';
import { readFileSync } from 'node:fs';

const server = createSecureServer(
  {
    key: readFileSync('localhost-key.pem'),
    cert: readFileSync('localhost-cert.pem'),
  },
  (req, res) => {
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end('ok');
  },
);

server.listen(8443);

Auf der Client-Seite verbindest du dich mit http2.connect(authority) und schickst Requests über client.request({ ':path': '/' }). Wer tiefer in die Node-Interna und das Zusammenspiel mit dem Event Loop einsteigen möchte, findet in meinem Node.js-Training die passende Grundlage.

Server-Sent Events: der einseitige Stream

Jetzt zum eigentlichen Kern. Bei sehr vielen Live-Anwendungen fließen die Daten fast nur in eine Richtung: Der Server hat etwas Neues, und der Client soll es erfahren. Ein Aktienticker, ein Build-Fortschritt, eine Benachrichtigungsleiste, ein Live-Log. Für all das braucht man keinen bidirektionalen Kanal, keinen Protokoll-Upgrade und keine eigene Reconnect-Logik. Man braucht Server-Sent Events.

SSE ist kein spezielles Modul und kein neues Protokoll. Es ist gewöhnliches HTTP mit einem bestimmten Content-Type: text/event-stream. Der Server hält die Antwort offen und schreibt in einem simplen Zeilenformat immer neue Events hinein. Der Browser hat mit EventSource einen nativen Client dafür, und – das ist der eigentliche Charme – dieser Client reconnectet von allein, wenn die Verbindung abreißt.

Das Wire-Format ist bewusst schlicht. Eine Nachricht besteht aus Feldern, jedes auf einer eigenen Zeile, und endet mit einer Leerzeile:

data: hello world

event: tick
data: {"count":42}
id: 42

: this is a keep-alive comment

Die wichtigsten Felder sind data: für den Nutzinhalt, event: für einen benannten Event-Typ, id: für eine fortlaufende Kennung und retry: für das Reconnect-Delay in Millisekunden. Zeilen, die mit einem Doppelpunkt beginnen, sind Kommentare und eignen sich gut als Keep-alive-Ping. Entscheidend ist die doppelte Leerzeile, das \n\n, am Ende jeder Nachricht – vergisst man sie, wird das Event schlicht nie ausgeliefert.

sequenceDiagram
  participant B as Browser (EventSource)
  participant S as Node Server
  B->>S: GET /events
  S-->>B: 200 text/event-stream
  loop alle 1s
    S-->>B: data: { ... }<br/>(offen gehaltene Antwort)
  end
  Note over B,S: Verbindung bricht ab
  B->>S: GET /events<br/>Last-Event-ID: 42
  S-->>B: 200 text/event-stream<br/>(setzt ab id 42 fort)

Ein SSE-Endpunkt in Node

Der Code dafür ist erfreulich kurz. Wichtig sind die Response-Header, das sofortige Flushen und – das übersehen die meisten beim ersten Mal – das Aufräumen, wenn der Client die Verbindung schließt.

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  if (req.url !== '/events') {
    res.writeHead(404).end();
    return;
  }

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();

  let count = 0;
  const timer = setInterval(() => {
    count += 1;
    const payload = { count, time: Date.now() };
    res.write(`id: ${count}\n`);
    res.write(`event: tick\n`);
    res.write(`data: ${JSON.stringify(payload)}\n\n`);
  }, 1000);

  req.on('close', () => {
    clearInterval(timer);
  });
});

server.listen(3000);

Der req.on('close', ...)-Handler ist nicht optional. Ohne ihn läuft das setInterval munter weiter, auch wenn der Browser die Seite längst geschlossen hat. Der Handler schreibt dann in eine tote Verbindung, der Timer bleibt hängen, und bei vielen Clients hast du ein handfestes Speicherleck. In Projekten ist das der Fehler, der mir am zuverlässigsten begegnet – der Endpunkt funktioniert im Happy Path prima, und erst unter Last fällt auf, dass niemand aufräumt.

Auf der Browserseite ist der Client fast schon unspektakulär:

const es = new EventSource('/events');

es.onmessage = (event) => {
  // fires for messages without an explicit event: field
  console.log('default message', event.data);
};

es.addEventListener('tick', (event) => {
  const payload = JSON.parse(event.data);
  render(payload);
});

es.onerror = (err) => {
  // EventSource reconnects on its own; log and let it recover
  console.warn('connection lost, reconnecting…', err);
};

// later, when you no longer need it
// es.close();

Der Punkt, der mir dabei am wichtigsten ist: Du musst den Reconnect nicht selbst bauen. EventSource merkt sich die letzte gesehene id: und schickt sie beim Wiederverbinden im Header Last-Event-ID mit. Wenn dein Server diesen Header auswertet, kann er nahtlos ab der letzten Nachricht fortsetzen. Das retry:-Feld steuert, wie lange der Browser vor einem neuen Versuch wartet.

Der Fallstrick, der dich garantiert erwischt

Es gibt einen Klassiker, und ich habe ihn oft genug live miterlebt, um ihn hier ausdrücklich hinzuschreiben: Reverse-Proxy-Buffering. In der Entwicklung läuft alles gegen den nackten Node-Prozess und funktioniert tadellos. Sobald ein nginx davorsteht, kommen die Events plötzlich nicht mehr einzeln im Sekundentakt an, sondern gebündelt und verzögert – oder scheinbar gar nicht. Der Grund: nginx puffert Response-Bodies per Default, und ein text/event-stream ist für den Puffer erst einmal nur ein Body, der noch nicht fertig ist.

Die Lösung ist der Response-Header X-Accel-Buffering: no, den nginx erkennt und daraufhin für diese eine Antwort das Buffering abschaltet. Alternativ setzt man proxy_buffering off; in der nginx-Konfiguration. Deshalb steht dieser Header oben schon im Beispiel – nicht, weil er in jeder Umgebung nötig ist, sondern weil er dich vor genau dem Debugging-Nachmittag bewahrt, der sonst unweigerlich kommt.

Zwei weitere Stolpersteine gehören dazu. Erstens das schon erwähnte Format: Zeilenumbrüche innerhalb der Nutzdaten musst du entweder auf mehrere data:-Zeilen aufteilen oder vermeiden – ein rohes \n mitten im JSON zerreißt die Nachricht. Zweitens eine Einschränkung von EventSource selbst: Der native Client kann nur GET und keine eigenen Header setzen. Authentifizierung läuft deshalb typischerweise über Cookies oder einen Token im Query-String, nicht über einen Authorization-Header.

Wann SSE und wann WebSocket

Ausschlaggebend ist die Richtung der Daten. Die Gegenüberstellung, an der sich das festmachen lässt:

  • Richtung: SSE ist einseitig, Server zu Client. WebSocket ist voll bidirektional.
  • Protokoll: SSE ist gewöhnliches HTTP mit einem speziellen Content-Type. WebSocket braucht einen Protokoll-Upgrade der Verbindung.
  • Reconnect: Bei SSE eingebaut inklusive Last-Event-ID. Bei WebSocket baust du ihn selbst.
  • Nutzdaten: SSE transportiert ausschließlich UTF-8-Text. WebSocket kann Text und Binärdaten.
  • Infrastruktur: SSE läuft durch normale Proxies, Auth und Kompression. WebSocket braucht überall Upgrade-Support.
  • Typischer Einsatz: SSE für Notifications, Live-Feeds und Fortschrittsanzeigen. WebSocket für Chat, Spiele und kollaborative Editoren.

Wenn der Client im Wesentlichen nur zuhört, reicht SSE. Erst wenn du einen echten Rückkanal mit niedriger Latenz brauchst – der Nutzer tippt, klickt, bewegt eine Spielfigur – ist WebSocket die richtige Investition. Für den Rest ist SSE weniger Code, weniger Infrastruktur-Gefummel und weniger selbstgebaute Fehlerbehandlung.

Transportwahl als Entwurfsentscheidung

Die Gegenüberstellung oben beantwortet die Frage nach der Datenrichtung. In der Praxis entscheidet aber selten die Richtung allein, sondern was die Wahl im Betrieb und im Test kostet. Drei Achsen tragen die Entscheidung, und sie lassen sich in dieser Reihenfolge durchgehen.

Zuerst die Datenrichtung, weil sie am härtesten ist. Fließt der Verkehr im Wesentlichen vom Server zum Client, ist der Rückkanal von WebSocket totes Gewicht, das trotzdem betreut werden will. Fließt er in beide Richtungen mit niedriger Latenz, ist SSE das falsche Werkzeug, und jeder Versuch, den Rückkanal über einen zweiten HTTP-Request nachzubauen, wird schnell unangenehmer als ein sauberer WebSocket. Diese Achse ist keine Geschmacksfrage, sondern legt fest, welche Fähigkeit du überhaupt brauchst.

Dann die Betriebsumgebung, weil sie über die laufenden Kosten entscheidet. SSE ist gewöhnliches HTTP. Es läuft durch dieselben Reverse-Proxies, Load-Balancer, Auth-Layer und Kompressions-Stufen wie jeder andere Request, und das bestehende Monitoring sieht es ohne Sonderbehandlung. WebSocket verlangt an jeder Zwischenstation Upgrade-Support, eigene Timeouts für langlebige Verbindungen und oft Sticky Sessions, damit ein Client bei seinem Backend bleibt. Das ist alles beherrschbar, aber es ist zusätzliche Betriebsfläche, die jemand pflegen muss. Wer horizontal skaliert, merkt den Unterschied zuerst: Eine SSE-Verbindung ist ein Stream, den jeder Knoten beenden und der Client neu aufbauen kann, während ein WebSocket-Cluster in der Regel eine eigene Pub/Sub-Schicht braucht, damit ein Event auch den Knoten erreicht, auf dem der Empfänger hängt.

Zuletzt die Testbarkeit, weil sie die Entwicklungszyklen bestimmt. Einen SSE-Endpunkt teste ich mit Werkzeugen, die ohnehin da sind. Ein curl -N http://localhost:3000/events zeigt den Stream roh im Terminal, und im Browser-Test reicht der native EventSource, um Events und Reconnect zu prüfen. Ein WebSocket-Kanal braucht dafür meist eine eigene Client-Bibliothek im Testaufbau und mehr Choreografie, um Handshake, bidirektionalen Fluss und den selbstgebauten Reconnect abzudecken. Das schlägt nicht bei der ersten Zeile durch, sondern bei jedem Feature danach, das an dem Kanal hängt.

Wirtschaftlich ist die Rechnung unspektakulär und genau deshalb wichtig. Die günstigere Transportwahl ist die, die weniger fremde Systeme berührt und sich mit Bordmitteln testen lässt. Für einen Live-Feed, einen Fortschrittsbalken oder eine Benachrichtigungsleiste heißt das fast immer SSE: kürzere Zyklen, weil weniger Infrastruktur im Spiel ist, und weniger Reibung im Betrieb, weil nichts Neues überwacht werden muss. Die Grenze ist ebenso klar. Sobald echte Interaktion mit Rückkanal ins Spiel kommt, kauft der WebSocket seine höheren Betriebskosten mit einer Fähigkeit zurück, die SSE schlicht nicht hat. Die Wahl bewusst zu treffen kostet einmal ein paar Minuten Nachdenken. Sie reflexhaft zu treffen kostet über die Lebensdauer des Systems deutlich mehr.

SSE und HTTP/2 zusammen

Und hier schließt sich der Kreis zum Anfang. SSE braucht kein HTTP/2 – es läuft völlig zufrieden über HTTP/1.1. Aber es profitiert davon. Über HTTP/1.1 zählt jede offene SSE-Verbindung gegen das Sechser-Limit des Browsers pro Origin. Wer in mehreren Tabs oder mit mehreren parallelen Streams arbeitet, rennt schnell dagegen. Über HTTP/2 sind offene SSE-Verbindungen dagegen Streams innerhalb einer Verbindung. Wie viele davon gleichzeitig offen sein dürfen, handelt die Gegenstelle über SETTINGS_MAX_CONCURRENT_STREAMS aus; RFC 9113 empfiehlt mindestens 100, garantiert diesen Wert aber nicht. Das starre HTTP/1.1-Limit wird dadurch meist deutlich weniger relevant.

Weil SSE ein langlebiger Stream ist, der Daten in Häppchen ausliefert, lohnt sich außerdem ein Blick darauf, wie Node Streams intern behandelt und was passiert, wenn ein Client langsamer liest, als der Server schreibt. Wer viele Clients bedient, sollte Backpressure verstanden haben – dazu passt mein Beitrag zu Node.js-Streams und Backpressure.

HTTP/2 hat die Transportschicht so weit entspannt, dass viele alte Optimierungstricks überflüssig geworden sind, und Server-Sent Events bringen genau so viel Komplexität mit, wie ein einseitiger Stream nun einmal braucht – keinen Deut mehr. Die Grenze bleibt dabei scharf: Sobald die Daten wirklich in beide Richtungen fließen, ist WebSocket die richtige Investition. Nur fließen sie seltener in beide Richtungen, als der erste Reflex glauben macht. Wer die drei Achsen einmal durchgeht, bevor er sich festlegt, spart sich im günstigen Fall eine komplette Pub/Sub-Schicht.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare