GraphQL Subscriptions in Production: Real-Time APIs with Redis Pub/Sub and Pothos
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
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 100+ 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
}
}
Working With Viprasol
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
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.