GraphQL-Subscriptions über SSE: Echtzeit ohne WebSocket-Zwang
Subscriptions brauchen keinen WebSocket. Server-Sent Events genügen für den einseitigen Server-zu-Client-Kanal, während Query und Mutation normales HTTP bleiben, und das Schema entscheidet, was überhaupt ein Domänen-Event ist.
In fast jeder Schulung, in der Subscriptions zur Sprache kommen, fällt derselbe Satz: „Dann brauchen wir also WebSockets." Und fast immer folgt darauf eine Diskussion über Infrastruktur, Sticky Sessions, Load-Balancer-Konfiguration und Proxys, die WebSocket-Upgrades durchreichen müssen. Das ist eine lange Diskussion für eine Anforderung, die oft viel schlichter ist, als sie zunächst aussieht. Denn was die Teams meistens wirklich wollen, ist ein Live-Update in der Oberfläche: Eine Bestellung wechselt ihren Status, eine Kachel im Dashboard aktualisiert sich, ohne dass jemand die Seite neu lädt. Das ist ein einseitiger Fluss vom Server zum Client – und dafür ist ein WebSocket mit seinem bidirektionalen Vollduplex-Kanal deutlich mehr Maschinerie, als die Sache verlangt.
Deshalb möchte ich hier die These aus der ursprünglichen Fassung dieses Artikels schärfen und mit konkretem Code unterlegen: GraphQL-Subscriptions schreiben keinen WebSocket vor. Die Spezifikation ist an dieser Stelle transport-agnostisch. Sie sagt, dass eine Subscription einen Ereignisstrom liefert – nicht, über welche Leitung dieser Strom fließt. Und für den häufigen Fall „Server informiert Client" gibt es einen Kandidaten, der seit Jahren in jedem Browser eingebaut ist und über gewöhnliches HTTP läuft: Server-Sent Events.
Query und Mutation bleiben, wie sie sind
Bevor es um den Ereignisstrom geht, lohnt sich ein Blick auf das, was sich gar nicht ändert. Eine Query holt Daten, eine Mutation verändert Zustand – beide sind Anfrage und Antwort, ein Request, eine Response, fertig. Das ist normales HTTP, meist ein POST /graphql. Nichts an einer Subscription-Fähigkeit zwingt dazu, daran etwas zu drehen. Nur der dritte Operationstyp, die Subscription, braucht einen offenen Kanal, über den der Server nach und nach Ereignisse schickt.
Genau hier setzt Server-Sent Events an. SSE ist ein einseitiger Kanal vom Server zum Client über gewöhnliches HTTP. Der Server antwortet mit dem Content-Type text/event-stream und lässt die Verbindung offen; der Client öffnet sie über die eingebaute EventSource-Schnittstelle. Der Browser übernimmt dabei einiges, was man bei einer selbstgebauten Lösung mühsam nachrüsten müsste: Er reconnectet automatisch, wenn die Verbindung abreißt, und schickt beim Wiederverbinden die zuletzt gesehene Ereignis-ID im Header Last-Event-ID mit, damit der Server dort weitermachen kann, wo es abgerissen ist.
flowchart LR
subgraph Client
UI[Browser-App]
end
subgraph Server
GQL[GraphQL-Server]
end
UI -- "HTTP POST /graphql<br/>Query & Mutation" --> GQL
GQL -- "HTTP Antwort (JSON)" --> UI
GQL -- "text/event-stream<br/>Events via EventSource" --> UI
Das Bild zeigt den Kern: zwei Kanäle, ein Transport. Query und Mutation gehen den bekannten Weg über Request und Response. Der Ereignisstrom nutzt dieselbe HTTP-Infrastruktur, nur bleibt die Antwort offen und tröpfelt Ereignisse nach. Es gibt keinen Protokollwechsel, kein Upgrade-Handshake, keine gesonderte Behandlung durch Proxys, die WebSocket-Frames nicht kennen.
Der Serverteil: ein AsyncIterable aus PubSub
Auf der Serverseite ist ein Subscription-Resolver kein gewöhnlicher Feld-Resolver. Statt einer Funktion, die einen Wert zurückgibt, ist es ein Objekt mit einer subscribe-Methode, die ein AsyncIterable liefert. Aus diesem Iterable zieht der GraphQL-Server die Ereignisse und schickt sie an den passenden Client. Das gebräuchliche Werkzeug dafür ist das Paket graphql-subscriptions, zum jetzigen Zeitpunkt in Version 2.0.0. Es bringt eine PubSub-Klasse mit, deren asyncIterator(topic) genau so ein Iterable erzeugt.
import { PubSub, withFilter } from 'graphql-subscriptions';
const pubsub = new PubSub();
const ORDER_STATUS_CHANGED = 'ORDER_STATUS_CHANGED';
const resolvers = {
Subscription: {
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterator(ORDER_STATUS_CHANGED),
(payload, variables) =>
payload.orderStatusChanged.customerId === variables.customerId,
),
},
},
Mutation: {
advanceOrder: (_root, { id }) => {
const orderStatusChanged = advanceOrderInStore(id);
pubsub.publish(ORDER_STATUS_CHANGED, { orderStatusChanged });
return orderStatusChanged;
},
},
};
Zwei Dinge sind hier wichtig. Erstens der withFilter-Helfer: Er nimmt die Iterator-Funktion und eine Prädikatfunktion, die pro Ereignis entscheidet, ob es diesen Abonnenten überhaupt betrifft. So bekommt jeder Kunde nur die Statuswechsel seiner eigenen Bestellungen, obwohl alle über dasselbe Topic laufen. Zweitens die Publish-Stelle in der Mutation: Wenn sich fachlich etwas ereignet – hier: eine Bestellung rückt weiter –, wird das Ereignis mit pubsub.publish in den Strom gegeben. Der subscribe-Resolver und die Mutation sind über das Topic entkoppelt; die Mutation weiß nichts davon, wer gerade zuhört.
Ein Hinweis, der in der Praxis oft zu spät auffällt: Die mitgelieferte PubSub-Implementierung hält ihren Zustand im Arbeitsspeicher und funktioniert nur innerhalb eines einzelnen Prozesses. Sobald mehrere Server-Instanzen hinter einem Load-Balancer stehen, verpuffen Ereignisse – eine Instanz published, aber die Abonnenten hängen an einer anderen. Für den Produktivbetrieb braucht es dann ein externes Backend, etwa eine Redis-gestützte Variante, die das Publish über die Prozessgrenze hinweg verteilt. Für die lokale Entwicklung und den Einstieg genügt die eingebaute Variante völlig.
Der SSE-Endpunkt: der Strom nach draußen
Damit die Ereignisse aus dem Iterable auch beim Browser ankommen, braucht es einen Endpunkt, der den Strom als text/event-stream ausliefert. Konzeptionell sieht das in Express so aus:
app.get('/graphql/stream', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
const iterator = pubsub.asyncIterator(ORDER_STATUS_CHANGED);
(async () => {
for await (const event of iterator) {
res.write(`event: next\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
})();
req.on('close', () => {
if (typeof iterator.return === 'function') iterator.return();
});
});
Das Format eines SSE-Frames ist bewusst schlicht: Zeilen mit Feldnamen und Doppelpunkt. Das Feld data: trägt die Nutzlast, hier das serialisierte Ereignis; mehrere data:-Zeilen würde der Browser mit einem Zeilenumbruch zusammenfügen. Mit event: benennt man den Ereignistyp, sodass der Client gezielt darauf hören kann; ohne diese Zeile landet alles beim allgemeinen onmessage-Handler. Das Feld id: setzt die Ereignis-ID, die der Browser bei einem Reconnect als Last-Event-ID zurückschickt, und retry: gibt in Millisekunden vor, wie lange der Browser vor einem Wiederverbindungsversuch warten soll. Ein Frame endet mit einer Leerzeile, also einem doppelten \n. Zeilen, die mit einem Doppelpunkt beginnen, sind Kommentare und eignen sich als Heartbeat, um Zwischenschichten davon abzuhalten, eine ruhige Verbindung zu kappen.
Der req.on('close')-Teil ist kein Beiwerk. Wenn der Client die Verbindung schließt, muss der Iterator sauber beendet werden, sonst sammeln sich Abonnements an und der Prozess leckt Ressourcen. Dieses Aufräumen ist die Stelle, an der eine SSE-Anbindung entweder solide oder brüchig wird.
Der Client: EventSource, mehr nicht
Auf der Clientseite ist die Arbeit erstaunlich gering, weil der Browser den größten Teil übernimmt:
const es = new EventSource('/graphql/stream', { withCredentials: true });
es.addEventListener('next', (e) => {
const payload = JSON.parse(e.data);
render(payload.orderStatusChanged);
});
es.onerror = () => {
// The browser reconnects on its own; the retry: field controls the interval.
};
Kein manuelles Wiederverbinden, kein Timer, kein eigenes Heartbeat-Protokoll. Fällt die Verbindung aus, versucht der Browser von sich aus, sie erneut aufzubauen, und schickt die zuletzt empfangene Ereignis-ID mit. Die Option withCredentials sorgt dafür, dass Cookies über die Origin-Grenze hinweg mitgeschickt werden – praktisch, wenn die Authentifizierung über ein Session-Cookie läuft und der Endpunkt weiß, wer da gerade lauscht. Wer die Autorisierung im GraphQL-Server sauber in den Ausführungskontext legt, findet in GraphQL-Auth im Context das passende Muster; für den SSE-Endpunkt gilt dieselbe Logik – die Identität wird an der HTTP-Stelle festgestellt und steht dem Resolver zur Verfügung.
sequenceDiagram participant B as Browser (EventSource) participant S as GraphQL-Server participant P as PubSub B->>S: HTTP POST advanceOrder (Mutation) S->>P: pubsub.publish(topic, payload) P-->>S: asyncIterator liefert Event S-->>B: data:-Frame (event: next) Note over B,S: Verbindung bricht ab B->>S: Reconnect mit Last-Event-ID S-->>B: Ereignisse ab letzter ID
Das Schema ist ein Fachmodell, kein Ereignisrohr
Der technische Teil ist damit umrissen, aber der wichtigere Punkt aus der Schulungspraxis ist ein anderer. Sobald ein einfacher Weg da ist, Ereignisse an den Client zu schicken, wächst die Versuchung, alles Mögliche durch Subscriptions zu pumpen: jeden Tastendruck, jeden internen Tick, jede Log-Zeile. Das Schema ist aber ein Fachmodell und keine Sammelstelle für technische Signale. Eine Subscription sollte ein Ereignis abbilden, das fachlich etwas bedeutet – „Bestellung hat den Status gewechselt", „Kommentar wurde hinzugefügt", „Zahlung ist eingegangen". Ein orderStatusChanged sagt einem Konsumenten etwas über die Domäne. Ein serverTick oder internalMetric sagt ihm nichts, was er im Schema suchen würde.
Dieselbe Modellierungsdisziplin, die man bei Listen und Blättern anlegt – siehe die Überlegungen in GraphQL-Paginierung und Connections –, gilt hier genauso: Das Schema beschreibt die Sprache der Domäne, nicht die Interna der Übertragung. Wenn ein Ereignis nicht Teil dieser Sprache ist, gehört es nicht in eine Subscription.
Die falsche Abstraktion vermeiden
Damit sind wir bei der eigentlichen Entscheidung, und die ist keine GraphQL-Frage, sondern eine Architekturfrage. SSE, WebSocket und Messaging lösen unterschiedliche Probleme, und die häufigste Fehlerquelle ist, das eine als Ersatz für das andere zu behandeln.
- Server-Sent Events sind einseitig vom Server zum Client, laufen HTTP-nativ, reconnecten von selbst und transportieren ausschließlich Text in UTF-8. Sie passen genau dann, wenn die Oberfläche Live-Updates anzeigen soll: Statuswechsel, Benachrichtigungen, ein sich aktualisierendes Dashboard.
- WebSockets sind bidirektional und haben ein eigenes Protokoll mit eigenem Handshake. Sie sind die richtige Wahl, wenn der Client selbst laufend Daten in Echtzeit an den Server schickt – Chat, kollaboratives Bearbeiten, ein Spielzustand, alles mit dichtem Rückkanal.
- Messaging und Broker wie Kafka, AMQP oder Redis lösen Integration zwischen Systemen. Sie bieten Persistenz, Fan-out und Entkopplung im Backend. Eine GraphQL-Subscription ist kein Ersatz dafür; sie ist der letzte Meter zum Browser, nicht das Nervensystem zwischen den Diensten.
Wer diese drei sauber trennt, baut nichts Falsches. Wer sie vermischt – etwa einen WebSocket aufzieht, um einen rein einseitigen Update-Fluss zu bedienen, oder Subscriptions zweckentfremdet, um Dienste zu integrieren –, zahlt später mit Komplexität, die kein Feature rechtfertigt. Für den unidirektionalen Charakter einer typischen Subscription ist SSE schlicht das passendere Werkzeug.
Woher die SSE-Idee kommt – und was heute gilt
Die Verbindung von GraphQL und SSE ist nicht neu, und ihre Geschichte hilft, den heutigen Stand richtig einzuordnen. In der Frühzeit von Apollo Client, der 1.x-Ära, gab es das Paket subscriptions-transport-sse. Es wurde damals in einen Apollo-Client über das Konzept networkInterface eingehängt:
// HISTORICAL — Apollo Client 1.x, not today's API.
import {
SubscriptionClient,
addGraphQLSubscriptions,
} from 'subscriptions-transport-sse';
const sseClient = new SubscriptionClient('/subscriptions');
const client = new ApolloClient({
networkInterface: addGraphQLSubscriptions(httpInterface, sseClient),
});
Das ist ausdrücklich Legacy und gehört in kein neues Projekt. Sowohl networkInterface als auch der damalige SubscriptionManager – der Vorläufer des heutigen Musters mit PubSub.asyncIterator – sind Konzepte einer längst abgelösten Generation. Ich zeige das Fragment nur, um die Herkunft der Idee sichtbar zu machen: SSE als Transport für GraphQL-Ereignisse ist ein alter Gedanke, kein experimenteller.
Für die WebSocket-Seite lohnt ein Blick auf den aktuellen Stand, weil er die Transportfrage einrahmt. Das früher übliche subscriptions-transport-ws gilt inzwischen als nicht mehr gepflegt; sein Repository trägt seit Längerem einen Deprecation-Hinweis und wird nicht mehr aktiv weiterentwickelt. Der empfohlene Nachfolger für den WebSocket-Weg ist graphql-ws mit einem anderen Subprotokoll, das zum alten nicht kompatibel ist. Das ist hier nur Kontext: graphql-ws ist die Antwort, wenn man tatsächlich einen WebSocket braucht – nicht der Gegenspieler von SSE, sondern die Wahl für den anderen, bidirektionalen Fall.
Was ich aus der Praxis mitgebe
Wenn jemand in einer Schulung „Echtzeit" sagt, ist die erste nützliche Rückfrage nicht „WebSocket oder SSE?", sondern „In welche Richtung fließen die Daten?". In den allermeisten Fällen fließen sie vom Server zum Client, und dann ist Server-Sent Events die ruhigere, einfacher zu betreibende Lösung: gewöhnliches HTTP, eingebauter Reconnect, keine Sonderbehandlung in der Infrastruktur. Query und Mutation bleiben unangetastet, nur der Ereignisstrom bekommt seinen eigenen, offenen Kanal. Serverseitig liefert der subscribe-Resolver ein AsyncIterable aus einer PubSub, Ereignisse kommen per publish hinein, und withFilter sorgt dafür, dass jeder nur bekommt, was ihn angeht.
Zwei Dinge behalte ich dabei im Blick. Erstens, dass die eingebaute PubSub ihren Zustand im Speicher hält und über mehrere Instanzen hinweg ein externes Backend braucht. Zweitens, dass das Schema ein Fachmodell bleibt: Nur Ereignisse, die in der Sprache der Domäne etwas bedeuten, verdienen eine Subscription. Und die Transportwahl selbst ist am Ende eine schlichte Zuordnung – einseitig zum Client heißt SSE, echter Rückkanal heißt WebSocket, System-zu-System heißt Broker. Wer diese Zuordnung ernst nimmt, spart sich die lange Infrastrukturdiskussion, mit der dieser Text begonnen hat.
Weiterführende Quellen
- MDN – Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- MDN – EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource
- graphql-subscriptions (README/API): https://github.com/apollographql/graphql-subscriptions
- graphql-subscriptions (npm): https://www.npmjs.com/package/graphql-subscriptions
- Apollo Server – Subscriptions: https://www.apollographql.com/docs/apollo-server/data/subscriptions
- subscriptions-transport-ws (Legacy/archiviert): https://github.com/apollographql/subscriptions-transport-ws
- subscriptions-transport-sse (historischer Anlass): https://github.com/CodeCommission/subscriptions-transport-sse
- GraphQL Spec: https://spec.graphql.org/
Kommentare
Kommentar schreiben