Scaling GraphQL Subscriptions Over WebSockets Across Multiple Server Instances
AI generated
{ }
type
GraphQL · WebSockets · Scaling · Realtime
Scaling GraphQL Subscriptions Over WebSockets
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.

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

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.

11. FAQ: Scaling GraphQL Subscriptions Across Multiple Instances

1Why do subscriptions break across instances?
The triggering event can originate on a different instance than the waiting client. Without a shared channel it stays local and never reaches the client.
2Is Redis Pub/Sub enough for production?
Usually yes for live updates, dashboards and chat. Best-effort delivery is enough when the next state can be reloaded via a query.
3When to use Kafka instead of Redis?
For guaranteed delivery or very high event volume. More operational effort, but persistent messages and replayability.
4Do I need sticky sessions?
Usually not, since the TCP handshake already fixes the instance assignment for the connection's lifetime.
5What happens during rolling deployments?
Connections are hard-dropped. Clients with reconnect logic and backoff automatically rebuild the connection.
6graphql-ws vs. subscriptions-transport-ws?
graphql-ws is actively maintained with better ping/pong handling. The old package is outdated, only relevant for legacy clients.
7How do I detect dead connections?
Through regular ping frames. If the pong response is missing, the server actively closes the connection.
8How do I avoid reconnect storms?
With exponential backoff on the client side: growing delay between reconnect attempts up to a maximum.
9How do I monitor distributed subscriptions?
Prometheus metrics for connections and publish rate, plus structured logging with correlation IDs per lifecycle.
10Does Redis scale to many channels?
Without issue at thousands of channels, as long as CPU load at high fan-out width per channel is watched.