GraphQL Subscriptions for Real-Time Features
A hands-on guide to GraphQL subscriptions for real-time updates: WebSocket transport, subscription resolvers, filtering and scaling considerations.

REST APIs follow a request-response pattern — the client asks, the server answers. For real-time features like live notifications, chat messages, or dashboard updates, this model breaks down. You either poll repeatedly (wasting bandwidth and increasing latency) or you use a push mechanism. GraphQL subscriptions give you push semantics with the same type safety and schema-driven design as queries and mutations.
Subscriptions use WebSocket connections under the hood. The client declares what events it cares about, and the server pushes data through the open connection whenever those events occur.
Setting Up the Subscription Server
Most GraphQL servers support subscriptions through the graphql-ws protocol. Here is a setup using Apollo Server with Express:
import { createServer } from 'http';
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
const app = express();
const httpServer = createServer(app);
// Create WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Set up graphql-ws handler
const serverCleanup = useServer({ schema }, wsServer);
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);The key detail: HTTP handles queries and mutations while WebSocket handles subscriptions, both on the same /graphql endpoint. The graphql-ws library manages the WebSocket lifecycle — connection handshake, message framing, and keep-alive pings.
Defining Subscription Types
Subscriptions are defined in your schema alongside queries and mutations. They describe what events clients can subscribe to and what data shape they receive.
type Subscription {
# Simple subscription — receive every new message
messageCreated(channelId: ID!): Message!
# Filtered subscription — only specific order status changes
orderStatusChanged(orderId: ID!): OrderStatusUpdate!
# Broadcast subscription — all connected clients receive this
systemNotification: SystemNotification!
}
type Message {
id: ID!
channelId: ID!
author: User!
content: String!
createdAt: DateTime!
}
type OrderStatusUpdate {
orderId: ID!
previousStatus: OrderStatus!
newStatus: OrderStatus!
updatedAt: DateTime!
}
type SystemNotification {
id: ID!
level: NotificationLevel!
message: String!
timestamp: DateTime!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
enum NotificationLevel {
INFO
WARNING
CRITICAL
}Implementing Subscription Resolvers
Subscription resolvers use an AsyncIterator pattern. You publish events from your business logic and the resolver filters and delivers them to the right subscribers.
import { PubSub, withFilter } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Event name constants
const EVENTS = {
MESSAGE_CREATED: 'MESSAGE_CREATED',
ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
SYSTEM_NOTIFICATION: 'SYSTEM_NOTIFICATION',
} as const;
const resolvers = {
Subscription: {
messageCreated: {
// withFilter ensures clients only receive messages
// for the channel they subscribed to
subscribe: withFilter(
() => pubsub.asyncIterableIterator(EVENTS.MESSAGE_CREATED),
(payload, variables) => {
return payload.messageCreated.channelId === variables.channelId;
}
),
},
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator(EVENTS.ORDER_STATUS_CHANGED),
(payload, variables) => {
return payload.orderStatusChanged.orderId === variables.orderId;
}
),
},
systemNotification: {
// No filter — all subscribers receive all notifications
subscribe: () =>
pubsub.asyncIterableIterator(EVENTS.SYSTEM_NOTIFICATION),
},
},
Mutation: {
sendMessage: async (_: unknown, args: { channelId: string; content: string }, context: { userId: string }) => {
const message = await createMessage({
channelId: args.channelId,
authorId: context.userId,
content: args.content,
});
// Publish event — all matching subscribers receive it
await pubsub.publish(EVENTS.MESSAGE_CREATED, {
messageCreated: message,
});
return message;
},
},
};// ❌ Publishing without the correct payload shape
await pubsub.publish('MESSAGE_CREATED', { message: newMessage });
// Resolver expects payload.messageCreated, not payload.message
// Subscribers receive null/undefined — silent failure
// ✅ Payload key must match the subscription field name
await pubsub.publish('MESSAGE_CREATED', {
messageCreated: newMessage, // Matches subscription field name exactly
});Client-Side Subscription Handling
On the client, subscriptions integrate with your existing GraphQL client. Here is a React component using Apollo Client:
import { useSubscription, gql } from '@apollo/client';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageCreated($channelId: ID!) {
messageCreated(channelId: $channelId) {
id
content
author {
id
name
avatar
}
createdAt
}
}
`;
function ChatMessages({ channelId }: { channelId: string }) {
const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
onData: ({ data: subscriptionData }) => {
// Optional: handle each incoming message
const message = subscriptionData.data?.messageCreated;
if (message) {
playNotificationSound();
}
},
});
if (error) return <div>Connection error: {error.message}</div>;
if (loading) return <div>Connecting to channel...</div>;
const newMessage = data?.messageCreated;
return newMessage ? (
<div className="message">
<strong>{newMessage.author.name}:</strong> {newMessage.content}
</div>
) : null;
}Scaling Subscriptions in Production
The in-memory PubSub from graphql-subscriptions works for a single server instance. In production with multiple server instances behind a load balancer, you need a distributed pub/sub backend.
// ❌ In-memory PubSub — breaks with multiple server instances
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Server A publishes an event — only Server A's subscribers see it
// Clients connected to Server B miss the event entirely
// ✅ Redis-backed PubSub — works across all server instances
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const pubsub = new RedisPubSub({
publisher: new Redis({ host: 'redis-host', port: 6379 }),
subscriber: new Redis({ host: 'redis-host', port: 6379 }),
});
// Server A publishes → Redis broadcasts → all servers deliver
// to their connected subscribers// Connection management for production
interface SubscriptionConfig {
maxConnectionsPerUser: number;
connectionTimeoutMs: number;
heartbeatIntervalMs: number;
maxSubscriptionsPerConnection: number;
}
const productionConfig: SubscriptionConfig = {
maxConnectionsPerUser: 5, // Prevent connection leaks
connectionTimeoutMs: 30_000, // Close idle connections
heartbeatIntervalMs: 10_000, // Detect dead connections
maxSubscriptionsPerConnection: 20, // Limit resource usage
};
// WebSocket server context with authentication
const wsServerOptions = {
schema,
context: async (ctx: { connectionParams: Record<string, unknown> }) => {
const token = ctx.connectionParams?.authorization as string;
if (!token) {
throw new Error('Missing authentication token');
}
const user = await verifyToken(token);
return { user };
},
onConnect: async (ctx: { connectionParams: Record<string, unknown> }) => {
console.log('Client connected');
// Validate authentication before allowing subscription
},
onDisconnect: () => {
console.log('Client disconnected');
// Clean up per-connection resources
},
};Redis-backed pub/sub ensures that an event published by any server instance reaches all subscribers regardless of which instance they are connected to. This is the standard pattern for horizontally scaled subscription servers.
Key Takeaways
- Subscriptions add push semantics to GraphQL — clients declare interest, servers push data through persistent WebSocket connections
- Use
withFilterto ensure subscribers only receive events relevant to their query arguments - Payload keys must match subscription field names — mismatches cause silent null delivery
- Replace in-memory PubSub with Redis when running multiple server instances behind a load balancer
- Enforce connection limits and authentication on WebSocket connections to prevent resource exhaustion
- Subscriptions complement queries, they do not replace them — initial load uses a query, then subscriptions stream incremental updates


