GraphQL Subscriptions Deep Dive: From Theory to Production
GraphQL subscriptions enable real-time updates in your applications. But unlike queries and mutations, subscriptions are fundamentally different — they maintain persistent connections and push data from server to client. Let’s explore how they work and how to build them right.
How Subscriptions Work
At a high level, GraphQL subscriptions:
- Client sends a subscription operation
- Server maintains a persistent connection (usually WebSocket)
- When relevant events occur, server pushes updates
- Client receives updates and updates UI
subscription OnNewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
content
sender {
name
avatar
}
createdAt
}
}
Unlike queries that return once, subscriptions return a stream of results.
The Protocol: GraphQL over WebSocket
The most common transport for subscriptions is WebSocket. The graphql-ws protocol (which replaced the older subscriptions-transport-ws) defines how messages flow:
Client Server
| |
|------- connection_init ------>|
|<------ connection_ack --------|
| |
|------- subscribe ------------>|
| { id, payload } |
| |
|<-------- next ----------------|
| { id, payload } |
|<-------- next ----------------|
| { id, payload } |
| |
|--------- complete ----------->|
| { id } |
Server Implementation
Let’s look at a practical implementation using Node.js with graphql-yoga:
import { createServer } from 'http';
import { createYoga, createPubSub } from 'graphql-yoga';
const pubsub = createPubSub<{
'message:added': [channelId: string, message: Message];
'user:presence': [userId: string, status: 'online' | 'offline'];
}>();
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Message {
id: ID!
content: String!
sender: User!
createdAt: DateTime!
}
type Subscription {
messageAdded(channelId: ID!): Message!
userPresence(userId: ID!): PresenceUpdate!
}
`,
resolvers: {
Subscription: {
messageAdded: {
subscribe: (_, { channelId }) => {
return pubsub.subscribe('message:added', channelId);
},
resolve: (payload) => payload,
},
userPresence: {
subscribe: (_, { userId }) => {
return pubsub.subscribe('user:presence', userId);
},
resolve: ([userId, status]) => ({ userId, status }),
},
},
},
}),
});
// Somewhere in your app when a message is created:
function onMessageCreated(channelId: string, message: Message) {
pubsub.publish('message:added', channelId, message);
}
Client Implementation
On the client side, libraries like Apollo Client or urql make subscriptions straightforward:
import { useSubscription, gql } from '@apollo/client';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnNewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
content
sender {
name
avatar
}
createdAt
}
}
`;
function ChatMessages({ channelId }) {
const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
onSubscriptionData: ({ subscriptionData }) => {
// Handle new message
const newMessage = subscriptionData.data.messageAdded;
// Update local cache, show notification, etc.
},
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Message data={data.messageAdded} />;
}
Production Considerations
1. Connection Management
WebSocket connections are expensive. Implement proper lifecycle handling:
const client = createClient({
url: 'wss://api.example.com/graphql',
connectionParams: async () => ({
authToken: await getAuthToken(),
}),
retryAttempts: 5,
retryWait: async (retries) => {
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.min(1000 * 2 ** retries, 30000))
);
},
on: {
connected: () => console.log('WebSocket connected'),
closed: () => console.log('WebSocket closed'),
error: (error) => console.error('WebSocket error', error),
},
});
2. Authentication
Unlike HTTP requests, WebSocket connections don’t send headers per-message. Handle auth at connection time:
// Server
const yoga = createYoga({
context: async ({ connectionParams }) => {
if (!connectionParams?.authToken) {
throw new Error('Missing auth token');
}
const user = await validateToken(connectionParams.authToken);
return { user };
},
});
// Client
const client = createClient({
connectionParams: {
authToken: localStorage.getItem('token'),
},
});
3. Scaling Horizontally
Your pub/sub needs to work across server instances. Use Redis or similar:
import { createClient } from 'redis';
import { RedisPubSub } from 'graphql-redis-subscriptions';
const redis = createClient({ url: process.env.REDIS_URL });
const pubsub = new RedisPubSub({
publisher: redis.duplicate(),
subscriber: redis.duplicate(),
});
// Now publishes work across all server instances
pubsub.publish('message:added', message);
4. Filtering and Permissions
Not all subscribers should receive all events. Implement filtering:
const resolvers = {
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(['MESSAGE_ADDED']),
(payload, variables, context) => {
// Check if user has access to this channel
const hasAccess = context.user.channels.includes(variables.channelId);
// Check if message was in the requested channel
const matchesChannel = payload.channelId === variables.channelId;
return hasAccess && matchesChannel;
}
),
},
},
};
5. Rate Limiting
Protect against subscription abuse:
const MAX_SUBSCRIPTIONS_PER_USER = 10;
const activeSubscriptions = new Map<string, number>();
function enforceSubscriptionLimit(userId: string) {
const current = activeSubscriptions.get(userId) || 0;
if (current >= MAX_SUBSCRIPTIONS_PER_USER) {
throw new Error('Subscription limit exceeded');
}
activeSubscriptions.set(userId, current + 1);
return () => {
// Cleanup when subscription ends
activeSubscriptions.set(userId, (activeSubscriptions.get(userId) || 1) - 1);
};
}
Common Patterns
Optimistic Updates with Subscription Confirmation
function sendMessage(content: string) {
// 1. Add optimistic message to UI
const optimisticId = uuid();
addOptimisticMessage({ id: optimisticId, content, sending: true });
// 2. Send mutation
await createMessage({ content });
// 3. Subscription will bring real message, replace optimistic
// (handled by subscription callback)
}
// Subscription handler
onSubscriptionData: ({ subscriptionData }) => {
const realMessage = subscriptionData.data.messageAdded;
// Replace optimistic with real message
replaceOptimisticMessage(realMessage);
}
Presence Systems
// When user connects
pubsub.publish('user:presence', {
userId,
status: 'online',
lastSeen: new Date()
});
// When user disconnects (WebSocket close handler)
server.on('connection', (socket) => {
socket.on('close', () => {
pubsub.publish('user:presence', {
userId,
status: 'offline',
lastSeen: new Date()
});
});
});
Debugging Tips
- Use GraphQL Playground — Most playgrounds support subscriptions for testing
- Log subscription lifecycle — Connect, subscribe, unsubscribe, disconnect
- Monitor active subscriptions — Track count per topic and per user
- Trace message flow — Add correlation IDs to track events through the system
When NOT to Use Subscriptions
Subscriptions aren’t always the answer:
- Simple polling works fine — If updates are infrequent, polling is simpler
- One-time data fetches — Use queries, not subscriptions
- Server-to-server communication — Consider gRPC or message queues
- Massive fan-out — Millions of subscribers? Consider dedicated infrastructure
Wrapping Up
GraphQL subscriptions are powerful but come with complexity. Key takeaways:
- Understand the WebSocket protocol layer
- Use Redis or similar for horizontal scaling
- Implement proper auth and filtering
- Handle connection lifecycle gracefully
- Monitor and rate limit
When implemented well, subscriptions create magical real-time experiences for your users. When implemented poorly, they become a scaling nightmare. Choose wisely, and test thoroughly.
Working on GraphQL subscriptions? I’d love to hear about your experiences and challenges.