GraphQL Subscriptions Scalability: How to Build Real-Time APIs That Handle Millions of Live Events Without Melting Your Infrastructure
GraphQL Subscriptions are powerful — but most teams hit a wall the moment traffic spikes. This deep-dive shows you exactly how to architect, optimize, and scale GraphQL Subscriptions to handle millions of concurrent live events in production without dropping a single update.
TL;DR Quick Answer: GraphQL Subscriptions Scalability is achieved by decoupling your WebSocket transport layer from your PubSub broker (Redis, NATS, or Kafka), using subscription filtering at the broker level, horizontally scaling your subscription servers behind a sticky-session load balancer, and enforcing strict connection lifecycle management. Done right, you can support 500,000+ concurrent subscriptions with sub-100ms event delivery latency.
If you've ever shipped a product with live dashboards, collaborative features, or real-time notifications, you've likely reached for GraphQL Subscriptions. The developer experience is elegant — write a typed subscription query, and your client automatically receives pushed updates. But the moment you move beyond a few hundred concurrent users, GraphQL Subscriptions scalability becomes a deeply non-trivial engineering problem. Connections pile up, memory bloats, your single-node pub/sub bottlenecks, and suddenly your "real-time" feature is anything but.
At Apargo, we've architected real-time systems for SaaS platforms, AI-powered dashboards, and live communication products — including our own AI Greentick WhatsApp automation platform, which processes millions of live conversation events daily. This article is the battle-tested playbook we wish existed when we first started scaling GraphQL Subscriptions in production.
Why GraphQL Subscriptions Scalability Is Harder Than It Looks
Most GraphQL subscription tutorials show you a single Apollo Server instance with an in-memory PubSub from graphql-subscriptions. It works beautifully on localhost. In production, it's a ticking time bomb.
- In-memory PubSub is single-node only. Events published on Server A never reach subscribers connected to Server B.
- WebSocket connections are stateful. Unlike HTTP, they're persistent — a single server holds thousands of open connections, consuming file descriptors and memory.
- No built-in backpressure. If a slow client can't consume events fast enough, your server buffers pile up.
- Subscription resolvers run per-event, per-subscriber. A single hot topic with 10,000 subscribers and 100 events/sec means 1,000,000 resolver invocations per second.
- Authentication is tricky on reconnect. Token expiry mid-connection is a common production bug that leaks stale sessions.
Understanding these failure modes is the foundation of every architectural decision we'll walk through below.
The Right Architecture for GraphQL Subscriptions Scalability
At its core, a scalable subscription architecture separates three distinct concerns:
- Transport Layer — WebSocket servers that manage client connections
- Broker Layer — A distributed PubSub system (Redis, NATS, Kafka) that routes events across nodes
- Resolver Layer — The business logic that filters, transforms, and authorizes events before delivery
Here's the high-level topology that powers production-grade GraphQL Subscriptions scalability:
┌──────────────────────────────────────────────────────┐
│ Clients (WS) │
└──────────────┬───────────────────────┬───────────────┘
│ │
┌───────────▼──────────┐ ┌──────────▼───────────┐
│ Subscription Node 1 │ │ Subscription Node 2 │
│ (Apollo / graphql-ws│ │ (Apollo / graphql-ws│
│ + Redis Subscriber)│ │ + Redis Subscriber)│
└───────────┬──────────┘ └──────────┬───────────┘
│ │
└──────────┬────────────┘
│
┌───────────▼───────────┐
│ Redis PubSub / │
│ NATS / Kafka Broker │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ Event Publishers │
│ (Mutation Resolvers, │
│ Background Workers, │
│ Webhooks, AI Agents)│
└───────────────────────┘
Choosing Your PubSub Broker
The broker is the nervous system of your real-time architecture. Your choice here directly determines your ceiling for GraphQL Subscriptions scalability.
- Redis PubSub — Best for most SaaS products. Sub-millisecond fan-out, simple ops, integrates trivially with
graphql-redis-subscriptions. Ceiling: ~1M messages/sec on a well-tuned cluster. Use Redis Cluster for horizontal scale. - NATS JetStream — Excellent for high-throughput, low-latency event streaming with at-least-once delivery guarantees. Better than Redis for durable event replay.
- Apache Kafka — The right choice when subscriptions need to replay historical events, audit trails, or fan out to multiple downstream consumers beyond just GraphQL clients.
For the majority of product teams, Redis with Cluster mode is the pragmatic sweet spot. Let's wire it up.
Implementation: Redis-Backed GraphQL Subscriptions at Scale
Step 1 — Replace In-Memory PubSub with Redis
// pubsub.ts
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
// Use separate Redis clients for publish and subscribe
// ioredis requires dedicated connections for subscriber mode
const publisherClient = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
retryStrategy: (times) => Math.min(times * 50, 2000), // exponential backoff
});
const subscriberClient = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
retryStrategy: (times) => Math.min(times * 50, 2000),
});
export const pubsub = new RedisPubSub({
publisher: publisherClient,
subscriber: subscriberClient,
});
Step 2 — Define Subscription Resolvers with Filtering
One of the most common performance killers is broadcasting every event to every subscriber and letting the resolver filter client-side. Always filter at the broker level first, then apply fine-grained authorization in the resolver.
// resolvers/subscription.ts
import { withFilter } from 'graphql-subscriptions';
import { pubsub } from '../pubsub';
import { verifyToken } from '../auth';
const CHAT_MESSAGE_TOPIC = 'CHAT_MESSAGE_RECEIVED';
export const subscriptionResolvers = {
Subscription: {
messageSent: {
// withFilter prevents unnecessary resolver invocations
// Only subscribers matching the conversationId receive the event
subscribe: withFilter(
() => pubsub.asyncIterator(Related Articles
Explore more insights from our engineering and product teams.
