post

WebSockets sind nicht immer die Antwort

Warum Server-Sent Events für die meisten Echtzeit-Anforderungen die einfachere Wahl sind und wann sich der Preis einer echten WebSocket-Verbindung tatsächlich lohnt.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Ein Dashboard, das neue Bestellungen anzeigt. Eine Benachrichtigung, wenn der Build durchgelaufen ist. Ein Fortschrittsbalken für einen Import, der zwanzig Minuten braucht. Drei typische „Echtzeit“-Anforderungen – und in allen drei Fällen fließen die Daten ausschließlich in eine Richtung: vom Server zum Client. Trotzdem lautet die erste technische Antwort darauf erstaunlich oft: WebSockets, socket.io, eine persistente bidirektionale Verbindung.

Der Reflex „Echtzeit gleich WebSocket“ sitzt tief, und er ist verständlich. WebSockets sind das prominenteste Werkzeug im Regal, jede zweite Tutorial-Serie baut damit einen Chat. Nur: Ein Chat ist bidirektional. Ein Bestell-Dashboard ist es nicht. Wer für reines Server-zu-Client-Push die schwere Maschinerie anwirft, bezahlt für Fähigkeiten, die er nie abruft – und der Preis ist nicht null.

Was „Echtzeit“ im Anforderungstext wirklich bedeutet

Nimmt man die Anforderung ernsthaft auseinander, steht dort selten „Client und Server tauschen fortlaufend Nachrichten in beide Richtungen aus“. Dort steht: Der Nutzer soll etwas sehen, sobald es passiert, ohne F5 zu drücken. Das ist Benachrichtigung, nicht Konversation. Der Client redet weiterhin über gewöhnliche Requests mit dem Server – er klickt, er speichert, er lädt nach. Neu ist nur, dass der Server von sich aus etwas sagen darf.

Ganz nebenbei: Für manche dieser Fälle reicht sogar simples Polling. Ein Fortschrittsbalken, der alle zwei Sekunden einen GET absetzt, ist keine elegante Lösung, aber eine, die jeder im Team versteht, die jeder Cache und jeder Proxy kennt und die sich in einer Minute wegwerfen und ersetzen lässt. Die Frage ist also nicht nur „WebSocket oder nicht“, sondern wie viel Maschinerie die Anforderung überhaupt rechtfertigt.

Mir ist das in einem Shop-Projekt in diesem Frühjahr deutlich geworden. Das Team hatte für ein Auftrags-Dashboard socket.io eingebaut, und die Verbindung selbst war nie das Problem. Das Problem war alles drumherum: Nach jedem Deployment hingen Clients an toten Verbindungen, bis der selbstgebaute Reconnect griff. Der Load-Balancer brauchte Sonderbehandlung für das Protokoll-Upgrade. Und wenn ein Browser dreißig Sekunden offline war, fehlten genau die Bestellungen aus diesen dreißig Sekunden – das Wiederaufsetzen beim letzten bekannten Stand hatte schlicht niemand gebaut, weil das Protokoll es nicht mitbringt.

Der Preis einer WebSocket-Verbindung

Ein WebSocket beginnt als HTTP-Request, verlässt HTTP aber nach dem Upgrade-Handshake und wird zu einem eigenen Protokoll (RFC 6455) auf einer rohen TCP-Verbindung. Ab diesem Moment gilt nichts mehr von dem, was die HTTP-Welt an Infrastruktur und Semantik mitbringt. Konkret kauft man sich ein:

  • Verbindungs-Zustandsverwaltung auf dem Server: Wer ist verbunden, seit wann, mit welcher Session?
  • Eigene Heartbeats, denn tote TCP-Verbindungen fallen nicht von allein auf – Ping/Pong ist Handarbeit oder Bibliotheks-Feature, aber immer Teil der eigenen Verantwortung.
  • Reconnect-Logik im Client, inklusive Backoff und der Frage, was während der Funkstille verpasst wurde.
  • Sonderfälle in der Infrastruktur: Proxies, Load-Balancer und Firmen-Firewalls, die das Upgrade nicht kennen oder lange stille Verbindungen kappen.

Nichts davon ist unlösbar. Bibliotheken wie socket.io nehmen einem vieles ab, genau deshalb existieren sie. Aber es bleibt Komplexität, die betrieben, überwacht und im Fehlerfall verstanden werden will. Für einen Kanal, der nur in eine Richtung sendet, ist das ein schlechter Tausch.

const WebSocket = require('ws')
const server = new WebSocket.Server({ port: 8080 })

server.on('connection', socket => {
  socket.isAlive = true
  socket.on('pong', () => { socket.isAlive = true })
})

// dead connections are not detected for free – ping/pong is on us
setInterval(() => {
  server.clients.forEach(socket => {
    if (!socket.isAlive) return socket.terminate()
    socket.isAlive = false
    socket.ping()
  })
}, 30000)

Das ist nur der Heartbeat. Reconnect, Nachrichtenpuffer und das Wiederaufsetzen nach einer Unterbrechung stehen auf einem anderen Blatt – und auf dem steht in den meisten Projekten, die ich gesehen habe, erstaunlich wenig.

Server-Sent Events: Push über gewöhnliches HTTP

Server-Sent Events sind das unspektakuläre Gegenstück: ein ganz normaler HTTP-GET-Request, dessen Response nie endet. Der Server hält die Verbindung offen und schreibt Events als Text hinein, der Browser konsumiert sie über die EventSource-API. Kein Protokollwechsel, kein Upgrade, kein neues Vokabular für die Infrastruktur. Ein SSE-Endpoint in Express ist ein paar Zeilen:

const express = require('express')
const app = express()

let nextEventId = 0
const eventLog = []
const clients = []

app.get('/events', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive'
  })
  res.flushHeaders()

  // replay everything the client missed while it was offline
  const lastEventId = Number(req.get('Last-Event-ID') || 0)
  eventLog
    .filter(event => event.id > lastEventId)
    .forEach(event => res.write(toMessage(event)))

  clients.push(res)
  req.on('close', () => clients.splice(clients.indexOf(res), 1))
})

function broadcast(data) {
  const event = { id: ++nextEventId, data }
  eventLog.push(event)
  clients.forEach(res => res.write(toMessage(event)))
}

function toMessage({ id, data }) {
  return `id: ${id}\ndata: ${JSON.stringify(data)}\n\n`
}

app.listen(3000)

Der Client ist noch kürzer – und das ist der eigentliche Punkt:

const source = new EventSource('/events')

source.onmessage = event => {
  const order = JSON.parse(event.data)
  renderOrder(order)
}

// no reconnect code here: EventSource reconnects on its own
// and sends the Last-Event-ID header with every new attempt

Der Kommentar im Client-Code ist keine Behauptung, sondern Spezifikation. Reißt die Verbindung ab, verbindet sich EventSource selbstständig neu – die Wartezeit kann der Server sogar per retry:-Feld im Stream vorgeben. Und beim Wiederverbinden schickt der Browser automatisch den Header Last-Event-ID mit der ID des letzten empfangenen Events mit. Der Server kann damit exakt dort weitermachen, wo der Client stehengeblieben ist. Genau das Feature, das im Shop-Projekt so schmerzhaft fehlte, ist hier Teil des Protokolls.

sequenceDiagram
    participant B as Browser (EventSource)
    participant S as Server (Express)
    B->>S: GET /events
    S-->>B: Event 41 (Bestellung A-1041)
    S-->>B: Event 42 (Bestellung A-1042)
    Note over B,S: Verbindung bricht ab<br/>(Deployment, Funkloch, Proxy-Timeout)
    B->>S: GET /events mit Header Last-Event-ID 42
    S-->>B: Event 43 (alles seit 42 wird nachgeliefert)

Dazu kommt ein Vorteil, den man erst im Betrieb zu schätzen lernt: Der gesamte Kanal ist gewöhnliches HTTP. Load-Balancer routen ihn wie jeden anderen Request, Authentifizierung läuft über dieselben Header und Cookies wie der Rest der Anwendung, und Debugging braucht kein Spezialwerkzeug:

$ curl -N http://localhost:3000/events
id: 1
data: {"orderId":"A-1041","total":89.9}

id: 2
data: {"orderId":"A-1042","total":24.5}

Wer schon einmal versucht hat, einen WebSocket-Frame-Mitschnitt gemeinsam mit einem Kollegen aus dem Betrieb durchzugehen, weiß, was dieser eine curl-Aufruf wert ist. Der Stream ist lesbarer Text, jedes Event trägt seine ID, und dieselbe Ausgabe sieht auch der Browser.

Wo SSE ehrlich an Grenzen stößt

Damit das keine Werbeveranstaltung wird: Server-Sent Events haben Grenzen, und die gehören auf den Tisch, bevor die Entscheidung fällt.

Der Kanal ist strikt unidirektional. Will der Client etwas sagen, macht er das über einen normalen POST – für Formulare, Klicks und Kommandos ist das völlig in Ordnung und ohnehin der natürliche Weg, für einen hochfrequenten Rückkanal ist es keiner. Zweitens: Internet Explorer und Edge kennen EventSource bis heute nicht, dort braucht es ein Polyfill. Die verfügbaren Polyfills funktionieren ordentlich, aber es ist ein Baustein mehr, den man einplanen muss. Drittens teilt sich SSE unter HTTP/1.1 das Browser-Limit von ungefähr sechs Verbindungen pro Host – wer pro Tab mehrere Streams öffnet, rennt dagegen. Unter HTTP/2 entspannt sich das deutlich, weil alle Streams über eine einzige multiplexte Verbindung laufen. Und schließlich: Puffernde Proxies muss man gelegentlich per Konfiguration davon überzeugen, den Stream sofort durchzureichen statt ihn zu sammeln – das ist aber eine Zeile nginx-Konfiguration, kein Architekturproblem.

GraphQL-Subscriptions: derselbe Reflex, ein Stockwerk höher

Interessant wird es, wenn der Reflex in Frameworks einzieht. Im Apollo-Ökosystem laufen GraphQL-Subscriptions standardmäßig über subscriptions-transport-ws, also über WebSockets. Dabei sind Subscriptions das Lehrbuchbeispiel für unidirektionalen Datenfluss: Der Client meldet einmal an, was ihn interessiert, und ab dann redet nur noch der Server. Queries und Mutations gehen ohnehin über HTTP. Die bidirektionale Fähigkeit des Transports wird für einen einzigen Handshake benutzt – danach liegt sie brach, während ihre Betriebskosten bleiben.

Das hat mich in diesem Frühjahr genug beschäftigt, um es auszuprobieren: subscriptions-transport-sse ist meine API-kompatible SSE-Variante dieses Transports, entstanden in meiner GitHub-Organisation CodeCommission. Server-seitig hängt sich der SubscriptionServer an eine Express-App und den SubscriptionManager aus graphql-subscriptions, client-seitig wird der SubscriptionClient wie gewohnt an das Network-Interface von Apollo gehängt:

import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-sse'

const httpClient = createNetworkInterface({ uri: 'https://example.com/graphql' })
const sseClient = new SubscriptionClient('https://example.com/subscriptions')
const apolloClient = new ApolloClient({
  networkInterface: addGraphQLSubscriptions(httpClient, sseClient)
})

apolloClient.subscribeToMore({
  document: gql`
    subscription onOrderCreated {
      orderCreated {
        id
        total
      }
    }`,
  updateQuery: (prev, { subscriptionData }) => {
    // merge the new order into the store and return the new state
  }
})

Aus Sicht der Anwendung ändert sich nichts – dieselbe Subscription, dasselbe subscribeToMore. Nur der Transport darunter ist jetzt gewöhnliches HTTP mit allem, was oben beschrieben ist: automatischer Reconnect, Last-Event-ID, curl-Debugging. Als Praxistest habe ich im April eine kleine Presence-List damit gebaut – wer ist gerade online, React und Apollo im Frontend, Node im Backend, Events über SSE. Beides sind bewusst schlanke Projekte, eher Beleg als Produkt. Es ging mir darum zu zeigen, dass „GraphQL-Subscriptions brauchen WebSockets“ eine Konvention ist, keine Notwendigkeit.

Wann WebSockets die richtige Antwort sind

Nach so viel Gegenrede die andere Seite, denn es gibt sie. WebSockets sind die richtige Wahl, wenn tatsächlich beide Seiten fortlaufend und mit niedriger Latenz senden:

  • Kollaboratives Editieren, bei dem Tastenanschläge und Operationen beider Seiten zusammenlaufen
  • Cursor-Positionen und Präsenz-Signale, die viele Male pro Sekunde in beide Richtungen fließen
  • Spiele und Simulationen, in denen jede Runde Client-Eingaben gegen Server-Zustand abgeglichen wird
  • Frontends, bei denen der Overhead eines eigenen POST pro Client-Nachricht wirklich ins Gewicht fällt

Der gemeinsame Nenner: Der Rückkanal ist kein gelegentliches Kommando, sondern ein Strom. Dann lohnt sich das Protokoll, und dann ist auch die Betriebskomplexität gerechtfertigt – man nutzt ja, was man bezahlt. Die Entscheidungsfrage ist deshalb nicht „Brauchen wir Echtzeit?“, sondern „Wohin fließen die Daten, und wie oft?“.

flowchart TD
    A[Anforderung: Echtzeit] --> B{Fließen Daten fortlaufend<br/>in beide Richtungen?}
    B -- nein --> C[Server-Sent Events<br/>plus normale HTTP-Requests]
    B -- ja --> D{Ist der Rückkanal ein Strom<br/>mit Latenzanforderung?}
    D -- nein --> C
    D -- ja --> E[WebSockets –<br/>Betriebskosten einplanen]

Wer diese zwei Fragen ehrlich beantwortet, landet nach meiner Erfahrung in der großen Mehrheit der Fälle links im Diagramm. Das Auftrags-Dashboard, der Build-Monitor, der Import-Fortschritt, die Benachrichtigungsglocke oben rechts in der Anwendung – alles Fälle für SSE. Der Editor mit den Live-Cursorn der Kollegen ist der seltene, echte Fall für die rechte Seite.

Unterm Strich

Aus dem Shop-Projekt und den beiden SSE-Experimenten nehme ich vor allem eine Beobachtung mit: Die Kosten einer Technologie stecken selten dort, wo ihre Stärken beworben werden – sie stecken im Betrieb, im Debugging und in den Fehlerfällen, die das Protokoll nicht von sich aus abdeckt. WebSockets können etwas, das SSE nicht kann, und wenn man dieses Etwas braucht, sind sie konkurrenzlos. Aber „Echtzeit“ im Anforderungstext ist noch kein Beleg dafür, dass man es braucht. Meistens will da nur ein Server etwas mitteilen, und dafür reicht ein HTTP-Request, der nie endet – mit eingebautem Reconnect, eingebautem Wiederaufsetzen und einem Debugging-Erlebnis, das aus einem einzigen curl-Aufruf besteht.

Die langweiligere Technik gewinnt hier nicht trotz, sondern wegen ihrer Langeweile. Sie hat weniger bewegliche Teile – und jedes bewegliche Teil, das man gar nicht erst einbaut, kann nachts um drei nicht kaputtgehen.

Weiterführende Quellen

Kommentare