GraphQL Subscriptions über WebSockets auf mehrere Server-Instanzen skalieren
AI generated
{ }
type
GraphQL · WebSockets · Skalierung · Realtime
GraphQL Subscriptions über WebSockets skalieren
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.

18 Min. Lesezeit Redis Pub/Sub · Sticky Sessions · Kafka · graphql-ws Node.js · Apollo Server · Kubernetes

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.

11. FAQ: GraphQL Subscriptions über mehrere Instanzen skalieren

1Warum funktionieren Subscriptions nicht mehr bei mehreren Instanzen?
Das auslösende Event kann auf einer anderen Instanz entstehen als der wartende Client. Ohne gemeinsamen Kanal bleibt es lokal und erreicht den Client nie.
2Reicht Redis Pub/Sub für die Produktion?
Für Live-Updates, Dashboards und Chat meist ja. Best-Effort-Zustellung genügt, wenn der nächste State über eine Query nachladbar ist.
3Wann Kafka statt Redis?
Bei garantierter Zustellung oder sehr hohem Event-Volumen. Mehr operativer Aufwand, aber persistente Nachrichten und Nachlesbarkeit.
4Brauche ich Sticky Sessions?
Meist nicht zusätzlich nötig, da der TCP-Handshake die Instanz-Zuordnung für die Verbindungsdauer bereits fixiert.
5Was passiert bei Rolling Deployments?
Verbindungen werden hart getrennt. Clients mit Reconnect-Logik und Backoff bauen die Verbindung automatisch neu auf.
6graphql-ws vs. subscriptions-transport-ws?
graphql-ws ist aktiv gepflegt mit besserem Ping/Pong-Handling. Das alte Paket gilt als veraltet, nur noch für Legacy-Clients relevant.
7Wie erkenne ich tote Verbindungen?
Über regelmäßige Ping-Frames. Bleibt die Pong-Antwort aus, schließt der Server die Verbindung aktiv.
8Wie vermeide ich Reconnect-Stürme?
Mit exponentiellem Backoff auf Client-Seite: wachsende Verzögerung zwischen Reconnect-Versuchen bis zu einem Maximum.
9Wie überwache ich verteilte Subscriptions?
Prometheus-Metriken für Verbindungen und Publish-Rate, plus strukturiertes Logging mit Correlation-IDs pro Lifecycle.
10Skaliert Redis bei vielen Channels?
Problemlos bei tausenden Channels, solange die CPU-Last bei hoher Fan-Out-Breite pro Channel beobachtet wird.