post

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.

Wenn ich in einer Schulung frage, wie man Live-Daten vom Server in den Browser bekommt, fällt fast immer sofort ein Wort: WebSocket. Und dann erzähle ich davon, dass es für einen ganzen Haufen Anwendungsfälle eine deutlich schlichtere Antwort gibt, die noch dazu über die ganz normale HTTP-Infrastruktur läuft und Reconnects bereits eingebaut hat. Diese Antwort heißt Server-Sent Events. Bevor ich dahin komme, lohnt sich aber ein kurzer Blick auf die Schicht darunter, denn HTTP hat sich in den letzten Jahren stärker verändert, als viele im Alltag mitbekommen haben.

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 in Schulungen 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 und in allen aktuellen Release-Linien (18, 20, 22) verfügbar. 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 Schulungen ist das der Fehler, der am zuverlässigsten passiert – 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, den ich hier immer betone: 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

Die Frage kommt in jeder Schulung, und die ehrliche Antwort ist: Es hängt an der Richtung der Daten. Hier die Gegenüberstellung, an der ich es festmache:

  • 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.

Meine Faustregel: Wenn der Client im Wesentlichen nur zuhört, nimm 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.

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 einfach Streams innerhalb der einen Verbindung, und der übliche Wert liegt bei rund 100 gleichzeitigen Streams. Das Limit entschärft sich damit von selbst.

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.

Unterm Strich: HTTP/2 hat die Transportschicht so weit entspannt, dass viele alte Optimierungstricks überflüssig sind, und Server-Sent Events sind ein wunderbar unaufgeregtes Werkzeug, das genau die richtige Menge Komplexität mitbringt – nicht mehr. Wenn du das nächste Mal reflexartig zu WebSocket greifst, halte kurz inne und frage dich, ob die Daten wirklich in beide Richtungen fließen. Oft tun sie es nicht, und dann ist der einfachere Weg auch der bessere.

Weiterführende Quellen

Kommentare

Kommentar schreiben