wenn eine einzelne Instanz nicht mehr reicht
Ein einzelner Node-Prozess kann tausende WebSocket-Verbindungen für GraphQL Subscriptions halten, doch sobald mehrere Server-Instanzen hinter einem Load Balancer laufen, verliert ein Publish-Event den Kontakt zu Clients auf anderen Prozessen. Redis Pub/Sub, Kafka und ein sauberes Verbindungsmanagement lösen dieses Fan-Out-Problem und machen GraphQL Subscriptions horizontal skalierbar.
Inhaltsverzeichnis
- 1. Warum Subscriptions bei mehreren Instanzen brechen
- 2. WebSocket-Grundlagen: graphql-ws statt subscriptions-transport-ws
- 3. Das Fan-Out-Problem: Publish auf der falschen Instanz
- 4. Redis Pub/Sub als gemeinsamer Broker
- 5. Sticky Sessions vs. stateless WebSocket-Handling
- 6. Kafka oder NATS für hohen Event-Durchsatz
- 7. Verbindungsmanagement: Reconnects, Heartbeats, Backpressure
- 8. Monitoring und Debugging verteilter Subscriptions
- 9. Architektur-Entscheidungen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Subscriptions bei mehreren Instanzen brechen
GraphQL Subscriptions halten anders als Queries und Mutations eine dauerhafte, zustandsbehaftete Verbindung zum Client, meist über WebSockets. Solange nur ein einzelner Server-Prozess läuft, ist das unproblematisch: Ein Resolver publiziert ein Event über einen In-Memory-EventEmitter, und derselbe Prozess, der die WebSocket-Verbindung hält, liefert das Update sofort aus. Sobald aber mehrere Instanzen hinter einem Load Balancer laufen, was für jede produktive Umgebung mit mehr als minimaler Last der Normalfall ist, bricht dieses Modell.
Der Grund liegt in der Natur von GraphQL Subscriptions: Der Client, der ein Event empfangen soll, hält seine WebSocket-Verbindung zu genau einer Instanz. Das Event, das die Aktualisierung auslöst, etwa eine Mutation, die einen Bestellstatus ändert, kann aber auf einer völlig anderen Instanz ausgeführt werden. Ohne einen gemeinsamen Nachrichtenkanal zwischen den Prozessen erreicht das Update den wartenden Client nie, obwohl die Subscription technisch korrekt registriert wurde. Dieser Artikel zeigt, wie GraphQL Subscriptions über WebSockets zuverlässig auf mehrere Server-Instanzen skaliert werden, ohne Events zu verlieren.
2. WebSocket-Grundlagen: graphql-ws statt subscriptions-transport-ws
Bevor die Skalierung von GraphQL Subscriptions Sinn ergibt, muss das Transport-Protokoll stimmen. Das ältere Paket subscriptions-transport-ws gilt als unmaintained und hat bekannte Probleme mit Keep-Alive und Verbindungsabbrüchen. Der aktuelle Standard ist graphql-ws, das das graphql-transport-ws-Subprotokoll implementiert und explizite Connection-Init-Nachrichten, Ping/Pong-Frames und einen sauberen Complete-Handshake unterstützt. Apollo Server, Yoga und die meisten modernen GraphQL-Server unterstützen beide Protokolle parallel, um alte Clients nicht sofort auszuschließen.
Auf Client-Seite verbindet sich der Browser über das ws:// oder wss://-Schema, meist über denselben Host wie die HTTP-Endpunkte, aber mit eigenem Pfad. Der entscheidende Unterschied zu einer normalen HTTP-Anfrage: Die WebSocket-Verbindung bleibt geöffnet, bis Client oder Server sie aktiv schließen, ein Neustart des Servers oder ein Deployment beendet also alle offenen GraphQL Subscriptions abrupt. Genau dieses Verhalten macht das Verbindungsmanagement bei mehreren Instanzen zur zentralen Herausforderung.
# Subscription schema definition for an order status feed
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusEvent!
cartUpdated(cartId: ID!): Cart!
}
type OrderStatusEvent {
orderId: ID!
status: OrderStatus!
changedAt: String!
changedBy: String
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
3. Das Fan-Out-Problem: Publish auf der falschen Instanz
Das Kernproblem beim Skalieren von GraphQL Subscriptions heißt Fan-Out: Ein Event muss von der Instanz, auf der es entsteht, zu allen Instanzen gelangen, die gerade Clients mit einer passenden Subscription bedienen. Ohne Koordination existiert jeder In-Memory-PubSub isoliert pro Prozess. Führt Instanz A eine Mutation aus, die ein Event auslöst, aber der wartende Client hängt an Instanz B, verpufft das Event ungenutzt, und der Client sieht nie ein Update, obwohl im Frontend alles korrekt implementiert wurde.
Die Lösung ist immer dieselbe Grundidee: ein externer, von allen Instanzen erreichbarer Nachrichtenkanal, über den Publish-Events prozessübergreifend verteilt werden. Jede Instanz abonniert diesen Kanal und liefert eingehende Events nur an die WebSocket-Verbindungen aus, für die sie selbst zuständig ist. Damit wird aus lokalem Pub/Sub verteiltes Pub/Sub, und GraphQL Subscriptions funktionieren unabhängig davon, welche Instanz das auslösende Event verarbeitet hat.
4. Redis Pub/Sub als gemeinsamer Broker
Für die meisten Anwendungsfälle ist Redis Pub/Sub die pragmatischste Lösung, um GraphQL Subscriptions über mehrere Instanzen zu verteilen. Das Paket graphql-redis-subscriptions ersetzt den Standard-PubSub von Apollo Server durch eine Implementierung, die Events über Redis' PUBLISH/SUBSCRIBE-Kommandos verteilt. Jede Server-Instanz hält eine dauerhafte Redis-Verbindung im Subscriber-Modus und lauscht auf denselben Channel-Namen, sodass ein Publish von irgendeiner Instanz auf allen anderen ankommt.
Wichtig ist dabei, dass Redis Pub/Sub selbst keine Nachrichten persistiert: Ist eine Instanz zum Zeitpunkt des Publish nicht verbunden, geht die Nachricht für diese Instanz verloren. Für die meisten GraphQL Subscriptions-Anwendungsfälle wie Live-Updates ist das akzeptabel, da der nächste State über eine normale Query nachgeladen werden kann. Für garantierte Zustellung braucht es Redis Streams oder einen dedizierten Message Broker wie Kafka, siehe Abschnitt 6.
// pubsub.js — Redis-backed PubSub shared across all server instances
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
retryStrategy: (times) => Math.min(times * 50, 2000),
};
export const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
// resolvers.js — publish and subscribe using the shared channel
export const resolvers = {
Mutation: {
updateOrderStatus: async (_parent, { orderId, status }, { dataSources }) => {
const order = await dataSources.orders.updateStatus(orderId, status);
// Every instance connected to Redis receives this event
await pubsub.publish(`ORDER_STATUS_${orderId}`, {
orderStatusChanged: order,
});
return order;
},
},
Subscription: {
orderStatusChanged: {
subscribe: (_parent, { orderId }) =>
pubsub.asyncIterator(`ORDER_STATUS_${orderId}`),
},
},
};
5. Sticky Sessions vs. stateless WebSocket-Handling
Ein zweiter, oft unterschätzter Aspekt beim Skalieren von GraphQL Subscriptions betrifft den Load Balancer selbst. WebSocket-Verbindungen sind zustandsbehaftet: Einmal aufgebaut, muss jede weitere Frame-Kommunikation zur selben Backend-Instanz geroutet werden. Klassische Round-Robin-Load-Balancer, die jede neue TCP-Verbindung gleichmäßig verteilen, funktionieren dafür grundsätzlich, solange die Instanz-Zuordnung für die Lebensdauer der Verbindung stabil bleibt, was bei WebSockets automatisch der Fall ist, da die Verbindung nicht wie bei HTTP/1.1 Keep-Alive pro Request neu aufgebaut wird.
Problematisch wird es erst bei Autoscaling und Rolling Deployments: Skaliert die Instanzanzahl während eines Deployments herunter, werden aktive WebSocket-Verbindungen hart getrennt, und Clients müssen sich neu verbinden, potenziell auf einer anderen Instanz. Genau deshalb ist es bei GraphQL Subscriptions wichtig, dass Redis Pub/Sub oder ein vergleichbarer Broker die Verbindungs-Identität von der Event-Quelle entkoppelt: Es spielt keine Rolle, welche Instanz einen Client bedient, solange alle Instanzen dieselben Events empfangen. Sticky Sessions per Cookie sind für GraphQL Subscriptions selten nötig, da die Verbindungszuordnung ohnehin über den TCP-Handshake fixiert ist.
6. Kafka oder NATS für hohen Event-Durchsatz
Redis Pub/Sub stößt bei sehr hohem Event-Volumen oder wenn garantierte Zustellung nötig ist an Grenzen. Für GraphQL Subscriptions mit tausenden Events pro Sekunde, etwa Live-Preisänderungen in einem Marktplatz-Szenario, bietet sich Kafka oder NATS JetStream als Broker an. Beide unterstützen Partitionierung, sodass Events nach einem Schlüssel wie der Order-ID auf verschiedene Partitionen verteilt werden und die Verarbeitung horizontal parallelisiert werden kann, ohne die Reihenfolge innerhalb eines Schlüssels zu verlieren.
Der Architektur-Unterschied zu Redis: Kafka persistiert Nachrichten für eine konfigurierbare Retention-Zeit, sodass ein neu gestarteter Consumer verpasste Events nachträglich lesen kann. Für GraphQL Subscriptions bedeutet das, dass eine Server-Instanz nach einem Neustart nicht sofort wieder synchron sein muss, sondern sich über den letzten committeten Offset wieder einklinkt. Der Mehraufwand beim Betrieb, ein Kafka-Cluster braucht deutlich mehr operative Sorgfalt als eine Redis-Instanz, lohnt sich erst ab einer Event-Last, bei der Redis Pub/Sub tatsächlich an CPU- oder Netzwerkgrenzen stößt.
# docker-compose.yml excerpt — Redis and NATS JetStream as alternative brokers
# Redis: simple pub/sub, no persistence, good for most Subscription workloads
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
ports: ["6379:6379"]
nats:
image: nats:2-alpine
command: ["-js", "-sd", "/data"]
volumes: ["nats-data:/data"]
ports: ["4222:4222"]
# Check active subscriber count per channel (useful for debugging fan-out issues)
redis-cli PUBSUB NUMSUB ORDER_STATUS_1042
7. Verbindungsmanagement: Reconnects, Heartbeats, Backpressure
Ein oft übersehener Teil bei skalierten GraphQL Subscriptions ist das Verhalten bei Netzwerkproblemen. graphql-ws sendet standardmäßig Ping-Frames, um tote Verbindungen zu erkennen, bevor der Client selbst merkt, dass etwas nicht stimmt. Bleibt eine Pong-Antwort innerhalb eines konfigurierten Timeouts aus, schließt der Server die Verbindung aktiv, statt sie unbegrenzt offen zu halten und damit Ressourcen zu binden. Client-seitig implementiert die Bibliothek exponentielles Backoff für Reconnects, damit ein kurzer Netzwerkausfall nicht sofort zu einem Verbindungssturm auf allen Instanzen führt.
Backpressure wird relevant, wenn ein Client langsamer konsumiert, als Events publiziert werden, etwa bei einem mobilen Client mit schwacher Verbindung während einer Flash-Sale-Aktion mit hoher Event-Frequenz. Ohne Begrenzung wächst die interne Sende-Queue der WebSocket-Bibliothek unbegrenzt und kann eine Instanz aus dem Speicher drängen. Ein praktikables GraphQL Subscriptions-Pattern begrenzt die Queue-Länge pro Verbindung und verwirft ältere, überholte Events, etwa Zwischenstände eines Preis-Tickers, zugunsten des aktuellsten Zustands.
// client.ts — graphql-ws client with backoff and heartbeat handling
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'wss://api.mironsoft.de/graphql',
connectionParams: () => ({
authorization: `Bearer ${getAuthToken()}`,
}),
retryAttempts: Infinity,
// Exponential backoff: 1s, 2s, 4s ... capped at 30s
retryWait: async (retries) => {
const delay = Math.min(1000 * 2 ** retries, 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
},
keepAlive: 12000, // client-side ping every 12s
on: {
closed: (event) => console.warn('Subscription connection closed', event),
error: (err) => console.error('Subscription transport error', err),
},
});
const unsubscribe = client.subscribe(
{ query: `subscription($orderId: ID!) { orderStatusChanged(orderId: $orderId) { status } }`, variables: { orderId: '1042' } },
{
next: (data) => updateOrderStatusUI(data),
error: (err) => console.error(err),
complete: () => console.log('Subscription completed'),
}
);
8. Monitoring und Debugging verteilter Subscriptions
Verteilte GraphQL Subscriptions sind schwerer zu debuggen als zustandslose Queries, weil ein Fehler oft nicht in der Anfrage selbst liegt, sondern im Zusammenspiel zwischen Instanzen. Die wichtigste Kennzahl ist die Anzahl aktiver Subscriber pro Channel, die sich über PUBSUB NUMSUB in Redis oder ein entsprechendes Kafka-Consumer-Group-Lag-Dashboard beobachten lässt. Steigt die Zahl offener WebSocket-Verbindungen pro Instanz kontinuierlich an, ohne dass Clients aktiv die Seite verlassen, deutet das auf fehlerhafte Cleanup-Logik beim Verbindungsabbau hin.
Für produktionsnahes Debugging lohnt sich strukturiertes Logging mit einer Correlation-ID pro Subscription-Lifecycle, vom initialen connection_init über jedes ausgelieferte Event bis zum complete. So lässt sich nachvollziehen, ob ein Event tatsächlich publiziert wurde, aber den Client nicht erreicht hat, oder ob der Publish selbst nie stattgefunden hat. Prometheus-Metriken für aktive Verbindungen pro Instanz, Publish-Rate pro Channel und durchschnittliche Event-Latenz zwischen Publish und Delivery machen GraphQL Subscriptions in der Produktion beobachtbar, statt bei Kundenbeschwerden im Blindflug zu debuggen.
// metrics.js — exposing Prometheus metrics for distributed subscriptions
import client from 'prom-client';
const activeConnections = new client.Gauge({
name: 'graphql_subscription_connections_active',
help: 'Number of currently open WebSocket connections on this instance',
});
const publishCounter = new client.Counter({
name: 'graphql_subscription_events_published_total',
help: 'Total number of events published per channel',
labelNames: ['channel'],
});
const eventLatency = new client.Histogram({
name: 'graphql_subscription_event_latency_seconds',
help: 'Latency between publish and delivery to the client',
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2],
});
// Called from the WebSocket server's connection lifecycle hooks
export function onConnect() { activeConnections.inc(); }
export function onDisconnect() { activeConnections.dec(); }
export function onPublish(channel) { publishCounter.inc({ channel }); }
9. Architektur-Entscheidungen im Vergleich
Die Wahl des Brokers für skalierte GraphQL Subscriptions hängt von Event-Volumen, Zustellgarantien und operativem Aufwand ab. Die folgende Übersicht ordnet die gängigen Optionen nach Praxistauglichkeit für unterschiedliche Lastszenarien.
| Ansatz | Zustellgarantie | Betriebsaufwand | Geeignet für |
|---|---|---|---|
| In-Memory PubSub | Keine (single instance) | Minimal | Lokale Entwicklung, Prototypen |
| Redis Pub/Sub | Best effort | Niedrig | Live-Updates, Dashboards, Chat |
| Redis Streams | Garantiert (mit ACK) | Mittel | Order-Events, Audit-Trails |
| Kafka / NATS JetStream | Garantiert, persistent | Hoch | Hoher Durchsatz, Multi-Consumer |
Für die meisten Magento- und E-Commerce-Setups reicht Redis Pub/Sub völlig aus, da GraphQL Subscriptions dort meist UI-Updates auslösen, für die ein gelegentlich verpasstes Event über den nächsten regulären Query-Refresh kompensiert wird. Kafka lohnt sich erst, wenn Subscriptions Teil einer geschäftskritischen Event-Kette werden, etwa bei Lager-Synchronisation zwischen mehreren Systemen.
Mironsoft
GraphQL-APIs, Realtime-Infrastruktur und Magento-Integrationen
GraphQL Subscriptions, die auch unter Last zuverlässig laufen?
Wir analysieren eure Subscription-Architektur, richten Redis Pub/Sub oder Kafka als Broker ein und sorgen für sauberes Verbindungsmanagement über alle Server-Instanzen hinweg.
Architektur-Review
Analyse bestehender Subscription-Infrastruktur auf Skalierbarkeit und Schwachstellen
Broker-Setup
Redis Pub/Sub, Redis Streams oder Kafka je nach Event-Volumen einrichten
Monitoring
Prometheus-Metriken und Logging für verteilte WebSocket-Verbindungen aufbauen
10. Zusammenfassung
GraphQL Subscriptions über mehrere Server-Instanzen zu skalieren, ist kein Problem der GraphQL-Spezifikation selbst, sondern eine Frage der Infrastruktur rund um WebSockets. Das Fan-Out-Problem, bei dem ein Event auf einer anderen Instanz entsteht als der wartende Client, lässt sich nur mit einem gemeinsamen Nachrichtenkanal lösen. Redis Pub/Sub ist für die meisten Anwendungsfälle die richtige Wahl, Kafka oder NATS JetStream nur bei hohem Durchsatz oder Zustellgarantien.
Ebenso wichtig wie der Broker ist ein robustes Verbindungsmanagement: Heartbeats erkennen tote Verbindungen, exponentielles Backoff verhindert Reconnect-Stürme, und Backpressure-Grenzen schützen Instanzen vor überlasteten Sende-Queues. Wer diese Bausteine sauber kombiniert, bekommt GraphQL Subscriptions, die auch bei Autoscaling, Rolling Deployments und Lastspitzen zuverlässig Events an die richtigen Clients ausliefern.
GraphQL Subscriptions skalieren — Das Wichtigste auf einen Blick
Fan-Out-Problem
Events müssen von jeder Instanz zu jeder Instanz gelangen. Ohne gemeinsamen Broker gehen Updates auf anderen Prozessen verloren.
Redis als Standard-Broker
graphql-redis-subscriptions verteilt Publish-Events über Redis Pub/Sub an alle Instanzen. Best effort, aber ausreichend für die meisten Fälle.
Kafka bei hohem Durchsatz
Persistente, garantierte Zustellung mit Partitionierung. Mehr operativer Aufwand, lohnt sich erst bei hoher Event-Last.
Verbindungsmanagement
Heartbeats, exponentielles Backoff und Backpressure-Grenzen halten WebSocket-Verbindungen auch unter Last stabil.