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.
Wer für einen einseitigen Update-Fluss einen WebSocket aufzieht, holt sich ein zweites Protokoll in den Betrieb: Upgrade-Handshake, Sticky Sessions, Proxys und Load-Balancer, die davon wissen müssen, dazu Reconnect und Heartbeat in eigener Verantwortung. Der Anlass dafür ist meist bescheiden – eine Bestellung wechselt ihren Status, eine Kachel im Dashboard aktualisiert sich ohne Reload. Der Anlass ist nach einem Sprint erledigt, der Transport bleibt und will jahrelang betrieben werden.
Hinter „Echtzeit“ steht deshalb selten ein Syntaxproblem und fast immer eine Betriebsentscheidung. Ein bidirektionaler Vollduplex-Kanal, wie ihn ein WebSocket öffnet, ist kein Feature, das man geschenkt bekommt; er ist eine eigene Betriebsklasse mit eigenem Protokoll, eigenem Handshake und eigenen Ausfallmustern. Deshalb geht es in diesem Text weniger darum, wie man Subscriptions anbindet, als um die Entscheidung davor: SSE, WebSocket oder schlichtes Polling – entlang von Betriebskosten, Komplexität und Skalierung entschieden, nicht entlang der Frage, was gerade modern klingt.
Dabei schreiben GraphQL-Subscriptions den WebSocket gar nicht 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 Projektpraxis 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 fällt eine Ebene tiefer als GraphQL: in der Architektur. 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 ersetzt das nicht. Sie deckt den letzten Meter zum Browser ab; das Nervensystem zwischen den Diensten bleibt Aufgabe des Brokers.
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.
Entscheidungsrahmen: SSE, WebSocket oder Polling
Die saubere Trennung der Transporte ist das eine; im Projekt muss ich mich am Ende für einen entscheiden, und dafür hilft ein Rahmen entlang von drei Achsen: Betriebskosten, Komplexität und Skalierung. Diese drei Fragen beantworten die Transportwahl zuverlässiger als jede Feature-Liste, weil sie nach dem Preis im Betrieb fragen und nicht nach dem Funktionsumfang.
Polling ist der ehrliche Ausgangspunkt, den man zu schnell übergeht. Der Client fragt in einem Intervall „gibt es etwas Neues?“, und das ist über HTTP, Caching und Load-Balancer hinweg das am besten verstandene Muster überhaupt. Die Betriebskosten liegen konzeptionell nahe null, doch die Last auf dem Server wächst linear mit Client-Zahl und Frequenz und steckt die meiste Arbeit in leere Antworten. Die Komplexität ist minimal, weil es keinen offenen Kanal gibt, der aufräumen oder reconnecten müsste, und die Skalierung ist horizontal trivial, weil jede Anfrage für sich steht. Für Aktualisierungen im Sekunden- bis Minutenbereich, bei denen etwas Latenz niemandem wehtut, ist Polling oft die wirtschaftlich richtige Antwort – und die Option, die man zuerst prüfen sollte, bevor man etwas Aufwendigeres baut.
SSE sitzt in der Mitte und trifft genau den Fall „Server informiert Client zeitnah“. Die Betriebskosten bestehen aus einer offenen HTTP-Verbindung pro Client, die aber HTTP-nativ bleibt und keine Sonderbehandlung in der Infrastruktur verlangt. Die Komplexität ist gering auf dem Client, weil EventSource Reconnect und Last-Event-ID mitbringt; auf dem Server liegt der Aufwand vor allem im sauberen Aufräumen der Ströme. Die Skalierung hat ihre Kostenstelle in den vielen offenen Verbindungen, und der Zustand des PubSub muss über Instanzgrenzen verteilt werden, sobald mehr als ein Prozess läuft. Der Gewinn gegenüber Polling ist niedrige Latenz ohne Leerlauf; der Preis ist der offene Kanal, den man betreiben muss.
WebSocket ist die teuerste Klasse und rechtfertigt sich erst mit einem echten Rückkanal. Die Betriebskosten umfassen ein eigenes Protokoll mit Handshake, Sticky Sessions und Proxys, die das Upgrade kennen müssen. Die Komplexität steigt, weil Reconnect, Heartbeat und Zustandswiederherstellung bei mir liegen, nicht beim Browser. Die Skalierung ist am schwersten, weil langlebige, zustandsbehaftete Verbindungen sich horizontal am wenigsten leicht verteilen lassen. Dieser Aufwand zahlt sich aus, wenn der Client selbst laufend in Echtzeit sendet – Chat, kollaboratives Bearbeiten, Spielzustand. Für einen einseitigen Update-Fluss ist er Aufwand ohne Gegenwert.
Das Entscheidungskriterium ist damit knapp: erst prüfen, ob Polling reicht; wenn zeitnahe Server-zu-Client-Updates ohne Rückkanal gebraucht werden, SSE; und nur bei dichtem, bidirektionalem Verkehr WebSocket. Wer in dieser Reihenfolge fragt, wählt den billigsten Transport, der die Anforderung trägt, statt den mächtigsten, den man kennt – und genau diese Reihenfolge trennt eine Betriebsentscheidung von einer Geschmacksfrage.
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. Es steht nicht in Konkurrenz zu SSE; es deckt den anderen, den bidirektionalen Fall ab.
Was ich aus der Praxis mitgebe
Wenn jemand in einem Anforderungsgespräch „Echtzeit“ sagt, ist die erste nützliche Rückfrage nicht „WebSocket oder SSE?“, sondern „In welche Richtung fließen die Daten, und wie schnell müssen sie ankommen?“. Meist fließen sie vom Server zum Client und dürfen etwas Latenz vertragen, 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.
Der Gewinn dieser Zuordnung ist im Projektalltag zu spüren. Wer den billigsten tragfähigen Transport wählt, verkürzt die Zyklen bis zum funktionierenden Live-Update, weil die halbe Infrastrukturdiskussion entfällt, mit der dieser Text begann. Er senkt die Reibung im Betrieb, weil ein HTTP-nativer Strom weniger Sonderfälle in Proxys, Load-Balancern und Deployments erzeugt. Und er hebt die Qualität, weil das Schema ein Fachmodell bleibt und nur Ereignisse trägt, die in der Sprache der Domäne etwas bedeuten. Zwei Grenzen behalte ich dabei bewusst im Blick: Die eingebaute PubSub hält ihren Zustand im Speicher und braucht über mehrere Instanzen hinweg ein externes Backend, und die Transportwahl bleibt eine schlichte Zuordnung – Polling, wenn Latenz erlaubt ist, SSE für den einseitigen Weg zum Client, WebSocket nur für den echten Rückkanal. Diese klaren Grenzen tragen die kürzeren Zyklen und die geringere Reibung überhaupt erst.
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/
Wie fandest du diesen Beitrag?
Kommentare