when a single instance is not enough anymore
A single Node process can hold thousands of WebSocket connections for GraphQL Subscriptions, but once multiple server instances run behind a load balancer, a publish event loses contact with clients attached to other processes. Redis Pub/Sub, Kafka and clean connection management solve this fan-out problem and make GraphQL Subscriptions horizontally scalable.
Table of Contents
- 1. Why subscriptions break across multiple instances
- 2. WebSocket basics: graphql-ws instead of subscriptions-transport-ws
- 3. The fan-out problem: publish on the wrong instance
- 4. Redis Pub/Sub as a shared broker
- 5. Sticky sessions vs. stateless WebSocket handling
- 6. Kafka or NATS for high event throughput
- 7. Connection management: reconnects, heartbeats, backpressure
- 8. Monitoring and debugging distributed subscriptions
- 9. Architecture decisions compared
- 10. Summary
- 11. FAQ
1. Why subscriptions break across multiple instances
Unlike queries and mutations, GraphQL Subscriptions hold a long-lived, stateful connection to the client, typically over WebSockets. As long as only a single server process runs, this is unproblematic: a resolver publishes an event through an in-memory event emitter, and the same process holding the WebSocket connection delivers the update immediately. But once multiple instances run behind a load balancer, which is the normal case for any production environment beyond minimal load, this model breaks down.
The reason lies in the nature of GraphQL Subscriptions: the client that should receive an event holds its WebSocket connection to exactly one instance. The event that triggers the update, say a mutation that changes an order status, can run on a completely different instance. Without a shared message channel between processes, the update never reaches the waiting client, even though the subscription was technically registered correctly. This article shows how to reliably scale GraphQL Subscriptions over WebSockets across multiple server instances without losing events.
2. WebSocket basics: graphql-ws instead of subscriptions-transport-ws
Before scaling GraphQL Subscriptions makes sense, the transport protocol needs to be right. The older package subscriptions-transport-ws is considered unmaintained and has known issues with keep-alive and dropped connections. The current standard is graphql-ws, which implements the graphql-transport-ws subprotocol and supports explicit connection-init messages, ping/pong frames and a clean complete handshake. Apollo Server, Yoga and most modern GraphQL servers support both protocols in parallel to avoid excluding old clients immediately.
On the client side, the browser connects over the ws:// or wss:// scheme, usually the same host as the HTTP endpoints but on its own path. The crucial difference from a normal HTTP request: the WebSocket connection stays open until the client or server actively closes it, so a server restart or a deployment abruptly terminates all open GraphQL Subscriptions. This exact behavior is what makes connection management across multiple instances the central challenge.
# 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. The fan-out problem: publish on the wrong instance
The core problem when scaling GraphQL Subscriptions is fan-out: an event must travel from the instance where it originates to every instance currently serving clients with a matching subscription. Without coordination, every in-memory pub/sub exists in isolation per process. If instance A executes a mutation that triggers an event, but the waiting client is attached to instance B, the event fizzles unused, and the client never sees an update even though everything was implemented correctly on the frontend.
The solution is always the same basic idea: an external message channel reachable by all instances, over which publish events are distributed across processes. Every instance subscribes to this channel and only delivers incoming events to the WebSocket connections it is itself responsible for. That turns local pub/sub into distributed pub/sub, and GraphQL Subscriptions work regardless of which instance processed the triggering event.
4. Redis Pub/Sub as a shared broker
For most use cases, Redis Pub/Sub is the most pragmatic solution for distributing GraphQL Subscriptions across multiple instances. The package graphql-redis-subscriptions replaces Apollo Server's default pub/sub with an implementation that distributes events through Redis' PUBLISH/SUBSCRIBE commands. Every server instance keeps a persistent Redis connection in subscriber mode and listens on the same channel names, so a publish from any instance reaches all others.
Important: Redis Pub/Sub itself does not persist messages. If an instance is not connected at the moment of publish, the message is lost for that instance. For most GraphQL Subscriptions use cases like live updates, this is acceptable, since the next state can be reloaded through a regular query. For guaranteed delivery, you need Redis Streams or a dedicated message broker like Kafka, see section 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
A second, often underestimated aspect of scaling GraphQL Subscriptions concerns the load balancer itself. WebSocket connections are stateful: once established, every subsequent frame must be routed to the same backend instance. Classic round-robin load balancers, which distribute each new TCP connection evenly, work for this in principle, as long as the instance assignment stays stable for the lifetime of the connection, which happens automatically for WebSockets since the connection is not re-established per request the way HTTP/1.1 keep-alive works.
Things get tricky with autoscaling and rolling deployments: when the instance count scales down during a deployment, active WebSocket connections are hard-dropped, and clients must reconnect, potentially to a different instance. This is exactly why it matters for GraphQL Subscriptions that Redis Pub/Sub or a comparable broker decouples connection identity from the event source: it does not matter which instance serves a client, as long as all instances receive the same events. Sticky sessions via cookie are rarely necessary for GraphQL Subscriptions, since the connection assignment is already fixed by the TCP handshake.
6. Kafka or NATS for high event throughput
Redis Pub/Sub hits its limits at very high event volumes or when guaranteed delivery is required. For GraphQL Subscriptions with thousands of events per second, say live price changes in a marketplace scenario, Kafka or NATS JetStream is the better broker choice. Both support partitioning, so events are distributed across partitions by a key such as the order ID, and processing can be parallelized horizontally without losing ordering within a key.
The architectural difference from Redis: Kafka persists messages for a configurable retention period, so a freshly started consumer can read missed events afterward. For GraphQL Subscriptions, this means a server instance does not need to be immediately back in sync after a restart, but can rejoin from the last committed offset. The extra operational effort, a Kafka cluster demands considerably more operational care than a Redis instance, only pays off once event load actually pushes Redis Pub/Sub against CPU or network limits.
# 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. Connection management: reconnects, heartbeats, backpressure
An often overlooked part of scaled GraphQL Subscriptions is behavior under network problems. graphql-ws sends ping frames by default to detect dead connections before the client itself notices something is wrong. If a pong response does not arrive within a configured timeout, the server actively closes the connection instead of keeping it open indefinitely and tying up resources. On the client side, the library implements exponential backoff for reconnects, so a brief network outage does not immediately trigger a connection storm across all instances.
Backpressure becomes relevant when a client consumes slower than events are published, for example a mobile client on a weak connection during a flash sale with high event frequency. Without a limit, the internal send queue of the WebSocket library grows unbounded and can push an instance out of memory. A practical GraphQL Subscriptions pattern caps the queue length per connection and drops older, superseded events, such as intermediate states of a price ticker, in favor of the latest state.
// 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 and debugging distributed subscriptions
Distributed GraphQL Subscriptions are harder to debug than stateless queries, because a fault often does not lie in the request itself but in the interplay between instances. The most important metric is the number of active subscribers per channel, observable through PUBSUB NUMSUB in Redis or a corresponding Kafka consumer-group lag dashboard. If the number of open WebSocket connections per instance keeps rising without clients actively leaving the page, it points to faulty cleanup logic on connection teardown.
For production-grade debugging, structured logging with a correlation ID per subscription lifecycle pays off, from the initial connection_init through every delivered event to the complete. This makes it possible to tell whether an event was actually published but did not reach the client, or whether the publish itself never happened. Prometheus metrics for active connections per instance, publish rate per channel and average event latency between publish and delivery make GraphQL Subscriptions observable in production, instead of debugging blind on customer complaints.
// 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. Architecture decisions compared
Choosing the broker for scaled GraphQL Subscriptions depends on event volume, delivery guarantees and operational effort. The overview below ranks the common options by practical fit for different load scenarios.
| Approach | Delivery guarantee | Operational effort | Suited for |
|---|---|---|---|
| In-memory PubSub | None (single instance) | Minimal | Local development, prototypes |
| Redis Pub/Sub | Best effort | Low | Live updates, dashboards, chat |
| Redis Streams | Guaranteed (with ACK) | Medium | Order events, audit trails |
| Kafka / NATS JetStream | Guaranteed, persistent | High | High throughput, multi-consumer |
For most Magento and e-commerce setups, Redis Pub/Sub is entirely sufficient, since GraphQL Subscriptions there mostly trigger UI updates, where an occasionally missed event is compensated by the next regular query refresh. Kafka only pays off once subscriptions become part of a business-critical event chain, for example inventory synchronization between multiple systems.
Mironsoft
GraphQL APIs, realtime infrastructure and Magento integrations
GraphQL Subscriptions that stay reliable under load?
We analyze your subscription architecture, set up Redis Pub/Sub or Kafka as a broker and ensure clean connection management across all server instances.
Architecture review
Analysis of existing subscription infrastructure for scalability and weak spots
Broker setup
Redis Pub/Sub, Redis Streams or Kafka set up depending on event volume
Monitoring
Prometheus metrics and logging for distributed WebSocket connections
10. Summary
Scaling GraphQL Subscriptions across multiple server instances is not a problem of the GraphQL specification itself, but a question of the infrastructure around WebSockets. The fan-out problem, where an event originates on a different instance than the waiting client, can only be solved with a shared message channel. Redis Pub/Sub is the right choice for most use cases, Kafka or NATS JetStream only for high throughput or delivery guarantees.
Just as important as the broker is robust connection management: heartbeats detect dead connections, exponential backoff prevents reconnect storms, and backpressure limits protect instances from overloaded send queues. Combine these building blocks cleanly, and you get GraphQL Subscriptions that reliably deliver events to the right clients even under autoscaling, rolling deployments and traffic spikes.
Scaling GraphQL Subscriptions — Key Takeaways
Fan-out problem
Events must travel from every instance to every instance. Without a shared broker, updates on other processes get lost.
Redis as the default broker
graphql-redis-subscriptions distributes publish events through Redis Pub/Sub to all instances. Best effort, but sufficient for most cases.
Kafka for high throughput
Persistent, guaranteed delivery with partitioning. More operational effort, only worthwhile at high event load.
Connection management
Heartbeats, exponential backoff and backpressure limits keep WebSocket connections stable even under load.