GraphQL Subscriptions 2026: Real-Time APIs with Redis Pub/Sub
Build real-time GraphQL subscriptions with WebSockets, Redis pub/sub, and the Pothos schema builder. Includes authentication, scaling across instances, and cursor-based pagination.
GraphQL Subscriptions in Production: Real-Time APIs with Redis Pub/Sub and Pothos
Quick answer. GraphQL subscriptions push server-to-client updates for live dashboards, presence, order tracking, and chat. They fail at scale because one server holds WebSocket connections in memory. Redis pub/sub fixes this: any instance publishes an event and all instances forward it to their subscribers, using GraphQL Yoga and the Pothos schema builder.
GraphQL subscriptions are the right tool for a narrow set of problems: live dashboard updates, collaborative editing presence, order status tracking, and chat. For most data-fetching needs, queries and mutations are simpler and more reliable. But when you genuinely need server-to-client push, subscriptions deliver it within the GraphQL contract your frontend already understands.
The challenge is scaling. A single server maintains WebSocket connections in memory โ fine for one instance, broken when you add a second. Redis pub/sub bridges the gap: any server can publish an event, and all servers with subscribers for that event receive and forward it.
This post covers a production-ready setup with GraphQL Yoga, Pothos type-safe schema builder, and Redis-backed pub/sub that scales horizontally.
When to Use GraphQL Subscriptions (vs. Alternatives)
| Use Case | Subscriptions | SSE | Polling | WebSocket (raw) |
|---|---|---|---|---|
| Order status updates (user-specific) | โ | โ | โ ๏ธ (wasteful) | โ ๏ธ (over-engineered) |
| Live dashboard metrics | โ | โ | โ ๏ธ | โ ๏ธ |
| Collaborative editing (bi-directional) | โ | โ | โ | โ |
| Chat messages | โ | โ | โ | โ |
| Notification bell count | โ | โ simpler | โ | โ |
| Already using REST, not GraphQL | โ | โ | โ | โ |
If your app already uses GraphQL, subscriptions are the natural fit. If it doesn't, SSE is simpler for server-push.
Stack
- GraphQL Yoga โ production-ready GraphQL server built on Fetch API (works with Fastify, Node.js, Cloudflare Workers)
- Pothos โ code-first, type-safe GraphQL schema builder (no code generation needed)
- graphql-ws โ WebSocket subprotocol (
graphql-transport-ws) โ the 2026 standard - Redis โ pub/sub backbone for multi-instance scaling
- ioredis โ Redis client with reliable reconnection
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 1000+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Schema Definition with Pothos
// src/schema/builder.ts
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
import type PrismaTypes from '@pothos/plugin-prisma/generated';
import { db } from '@/db/prisma';
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes;
Context: {
userId: string;
tenantId: string;
};
Scalars: {
DateTime: { Input: Date; Output: Date };
JSON: { Input: unknown; Output: unknown };
};
}>({
plugins: [PrismaPlugin],
prisma: { client: db },
});
builder.queryType({});
builder.mutationType({});
builder.subscriptionType({});
// src/schema/order/types.ts
import { builder } from '../builder';
export const OrderStatus = builder.enumType('OrderStatus', {
values: ['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED'] as const,
});
export const OrderType = builder.prismaObject('Order', {
fields: (t) => ({
id: t.exposeID('id'),
status: t.expose('status', { type: OrderStatus }),
total: t.exposeFloat('total'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
customer: t.relation('customer'),
items: t.relation('items'),
}),
});
export const OrderEventType = builder.objectType('OrderEvent', {
fields: (t) => ({
orderId: t.exposeString('orderId'),
status: t.expose('status', { type: OrderStatus }),
timestamp: t.expose('timestamp', { type: 'DateTime' }),
message: t.exposeString('message', { nullable: true }),
}),
});
Redis Pub/Sub Layer
// src/pubsub/redis-pubsub.ts
import Redis from 'ioredis';
import { EventEmitter } from 'events';
type EventPayload = Record<string, unknown>;
export class RedisPubSub {
private publisher: Redis;
private subscriber: Redis;
private emitter = new EventEmitter();
constructor(redisUrl: string) {
const opts = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
retryStrategy: (times: number) => Math.min(times * 100, 3000),
};
this.publisher = new Redis(redisUrl, opts);
this.subscriber = new Redis(redisUrl, opts);
// Forward Redis messages to local EventEmitter
this.subscriber.on('message', (channel, message) => {
try {
const payload = JSON.parse(message);
this.emitter.emit(channel, payload);
} catch {
// Ignore malformed messages
}
});
}
async publish(channel: string, payload: EventPayload): Promise<void> {
await this.publisher.publish(channel, JSON.stringify(payload));
}
async subscribe(channel: string): Promise<void> {
await this.subscriber.subscribe(channel);
}
async unsubscribe(channel: string): Promise<void> {
await this.subscriber.unsubscribe(channel);
}
on(channel: string, listener: (payload: EventPayload) => void): void {
this.emitter.on(channel, listener);
}
off(channel: string, listener: (payload: EventPayload) => void): void {
this.emitter.off(channel, listener);
}
// Create an async generator for use in GraphQL subscriptions
async *asyncIterator<T extends EventPayload>(
channel: string,
): AsyncGenerator<T> {
await this.subscribe(channel);
const queue: T[] = [];
let resolve: (() => void) | null = null;
let done = false;
const listener = (payload: T) => {
queue.push(payload);
resolve?.();
resolve = null;
};
this.on(channel, listener as (p: EventPayload) => void);
try {
while (!done) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise<void>((r) => { resolve = r; });
}
}
} finally {
this.off(channel, listener as (p: EventPayload) => void);
await this.unsubscribe(channel);
}
}
}
export const pubsub = new RedisPubSub(process.env.REDIS_URL!);

๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Subscription Resolvers
// src/schema/order/subscriptions.ts
import { builder } from '../builder';
import { pubsub } from '@/pubsub/redis-pubsub';
import { OrderEventType } from './types';
import { verifyOrderAccess } from '@/services/authorization';
interface OrderEvent {
orderId: string;
status: string;
timestamp: Date;
message?: string;
}
builder.subscriptionField('orderUpdated', (t) =>
t.field({
type: OrderEventType,
args: {
orderId: t.arg.string({ required: true }),
},
// Auth check before subscribing
async authScopes(_, { orderId }, { userId }) {
return verifyOrderAccess(userId, orderId);
},
subscribe: async function* (_, { orderId }, { userId }) {
const channel = `order:${orderId}:events`;
// Yield current order status immediately (no missed events)
const order = await db.order.findUnique({ where: { id: orderId } });
if (order) {
yield {
orderId: order.id,
status: order.status,
timestamp: new Date(),
message: 'Current status',
} satisfies OrderEvent;
}
// Then stream future events from Redis
yield* pubsub.asyncIterator<OrderEvent>(channel);
},
resolve: (event) => event,
}),
);
// Subscribe to ALL orders for a tenant (admin dashboard)
builder.subscriptionField('tenantOrderFeed', (t) =>
t.field({
type: OrderEventType,
authScopes: { isAdmin: true },
subscribe: async function* (_, __, { tenantId }) {
yield* pubsub.asyncIterator<OrderEvent>(`tenant:${tenantId}:orders`);
},
resolve: (event) => event,
}),
);
Publishing Events from Business Logic
// src/services/orders.ts
import { pubsub } from '@/pubsub/redis-pubsub';
import { db } from '@/db/prisma';
export async function shipOrder(
orderId: string,
trackingNumber: string,
): Promise<void> {
const order = await db.order.update({
where: { id: orderId },
data: { status: 'SHIPPED', trackingNumber },
include: { customer: true },
});
const event = {
orderId,
status: 'SHIPPED',
timestamp: new Date(),
message: `Your order has shipped. Tracking: ${trackingNumber}`,
};
// Publish to order-specific channel (watched by customer)
await pubsub.publish(`order:${orderId}:events`, event);
// Also publish to tenant feed (watched by admin dashboard)
await pubsub.publish(`tenant:${order.tenantId}:orders`, event);
}
GraphQL Yoga Server with WebSocket Support
// src/server.ts
import Fastify from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useServer } from 'graphql-ws/lib/use/fastify-websocket';
import fastifyWebsocket from '@fastify/websocket';
import { schema } from './schema';
import { createContext } from './context';
const app = Fastify({ logger: true });
await app.register(fastifyWebsocket);
const yoga = createYoga({
schema,
context: createContext,
graphqlEndpoint: '/graphql',
subscriptionsProtocol: 'WS',
logging: app.log,
});
// HTTP + WebSocket on same endpoint
app.route({
url: yoga.graphqlEndpoint,
method: ['GET', 'POST', 'OPTIONS'],
handler: async (req, reply) => {
const response = await yoga.handleNodeRequestAndResponse(req, reply, {
req,
reply,
});
return response;
},
wsHandler: useServer(
{
schema,
context: async (ctx) => {
// Authenticate WebSocket connection via connectionParams
const token = ctx.connectionParams?.authorization as string;
if (!token) throw new Error('Unauthorized');
const user = await verifyJWT(token);
return { userId: user.sub, tenantId: user.tenantId };
},
onConnect: (ctx) => {
app.log.info({ ip: ctx.extra.socket.remoteAddress }, 'WS connected');
},
onDisconnect: (ctx) => {
app.log.info({ ip: ctx.extra.socket.remoteAddress }, 'WS disconnected');
},
},
app.websocketServer,
),
});
await app.listen({ port: 4000, host: '0.0.0.0' });
React Client with urql
// src/providers/graphql.tsx
import { createClient, subscriptionExchange, cacheExchange, fetchExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';
const wsClient = createWSClient({
url: process.env.NEXT_PUBLIC_WS_URL ?? 'wss://api.myapp.com/graphql',
connectionParams: async () => ({
// JWT from auth store
authorization: await getAuthToken(),
}),
retryAttempts: 5,
on: {
error: (err) => console.error('WS error:', err),
},
});
export const gqlClient = createClient({
url: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.myapp.com/graphql',
exchanges: [
cacheExchange,
fetchExchange,
subscriptionExchange({
forwardSubscription: (request) => ({
subscribe: (sink) => ({
unsubscribe: wsClient.subscribe(request as any, sink),
}),
}),
}),
],
});
// src/components/OrderTracker.tsx
import { useSubscription } from 'urql';
import { gql } from 'urql';
const ORDER_UPDATED = gql`
subscription OrderUpdated($orderId: String!) {
orderUpdated(orderId: $orderId) {
orderId
status
timestamp
message
}
}
`;
export function OrderTracker({ orderId }: { orderId: string }) {
const [result] = useSubscription({
query: ORDER_UPDATED,
variables: { orderId },
});
const event = result.data?.orderUpdated;
return (
<div className="order-tracker">
{result.fetching && <p>Connecting...</p>}
{result.error && <p className="text-red-600">Connection error</p>}
{event && (
<div>
<StatusBadge status={event.status} />
<p className="text-sm text-gray-500">
{new Date(event.timestamp).toLocaleTimeString()}
</p>
{event.message && <p>{event.message}</p>}
</div>
)}
</div>
);
}
Scaling: Connection Counts and Infrastructure
| Concurrent Connections | Setup | Infrastructure |
|---|---|---|
| <1,000 | Single server, in-memory | 1ร Node.js instance (1 vCPU) |
| 1,000โ10,000 | Redis pub/sub, sticky sessions | 2โ4 Node.js instances + Redis |
| 10,000โ100,000 | Redis Cluster, horizontal scale | 8โ16 instances + Redis Cluster |
| >100,000 | Dedicated WS tier (separate from HTTP) | Autoscaling group + ElastiCache |
WebSocket connections are stateful โ each connection stays open. At 10,000 concurrent connections per instance (Node.js limit varies by memory/CPU), plan capacity accordingly.
Load balancer config for sticky sessions (AWS ALB):
resource "aws_alb_target_group" "graphql_ws" {
name = "graphql-ws"
port = 4000
protocol = "HTTP"
target_type = "ip"
stickiness {
type = "lb_cookie"
cookie_duration = 86400 # 24h โ WebSocket reconnects go to same instance
enabled = true
}
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 10
}
}
What Viprasol Offers
Our team builds real-time GraphQL APIs for collaborative SaaS products โ from live dashboards to order tracking to multiplayer features โ with Redis-backed scaling and production-tested WebSocket infrastructure.
What we deliver:
- GraphQL Yoga + Pothos type-safe schema with subscriptions
- Redis pub/sub layer with reliable reconnection
- Authentication for WebSocket connections (JWT via connectionParams)
- Horizontal scaling setup with sticky sessions
- React/Next.js client integration with urql or Apollo
โ Discuss your real-time API requirements โ Web development services
Next Steps
- WebSockets vs Server-Sent Events
- GraphQL Federation
- Real-Time Analytics with ClickHouse and Kafka
- API Gateway Patterns
- Redis Caching Strategies
Scaling GraphQL Subscriptions Across Multiple Servers
Once your real-time API runs on more than one instance, in-memory event handling breaks: a client connected to one node never receives events published on another. This is the exact problem graphql-redis-subscriptions solves. By routing every published payload through Redis Pub/Sub, GraphQL subscriptions become broadcast-safe across every node behind your load balancer, so any subscriber receives events regardless of which server holds the WebSocket connection.
In practice, you wire a single RedisPubSub instance into your schema's subscription resolvers, then publish and asyncIterator on named channels. For higher-throughput deployments, consider separate Redis connections for publishing and subscribing, connection retry logic, and channel namespacing to avoid cross-tenant leakage.
At Viprasol Tech, our senior engineers take full ownership of this layer รขโฌโ designing resilient, horizontally scalable real-time GraphQL backends that hold up under production load.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.