GraphQL Subscriptions for Real-Time Data
Build real-time features with GraphQL subscriptions using WebSockets, PubSub engines, and scalable event-driven architectures.
What you'll learn
- ✓How GraphQL subscriptions work over WebSockets
- ✓How to implement PubSub for event-driven updates
- ✓How to set up subscriptions with Apollo Server
- ✓How to consume subscriptions on the client
- ✓How to scale subscriptions with Redis PubSub
Prerequisites
- •Basic GraphQL queries and mutations
- •Familiarity with Node.js and Express
What Are GraphQL Subscriptions?
Queries fetch data once. Mutations change data once. Subscriptions keep a persistent connection open so the server can push updates to the client whenever relevant data changes. Under the hood, this typically uses WebSockets — a full-duplex communication channel over a single TCP connection.
Subscriptions are ideal for chat applications, live dashboards, notifications, collaborative editing, and any feature where users expect to see changes without refreshing the page.
The Subscription Lifecycle
A subscription follows this flow:
- The client sends a subscription operation to the server
- The server establishes a long-lived WebSocket connection
- When a relevant event occurs (usually triggered by a mutation), the server publishes the event
- The server pushes the data to all clients subscribed to that event
- The connection stays open until the client unsubscribes or disconnects
Setting Up the Server
First, install the required packages:
npm install @apollo/server graphql-ws ws graphql-subscriptions graphql
Define a schema with a subscription type:
type Message {
id: ID!
content: String!
author: String!
createdAt: String!
}
type Query {
messages(channelId: ID!): [Message!]!
}
type Mutation {
sendMessage(channelId: ID!, content: String!, author: String!): Message!
}
type Subscription {
messageSent(channelId: ID!): Message!
}
Implementing PubSub
The PubSub system is the backbone of subscriptions. It manages event channels and notifies subscribers when new events are published. Apollo provides a simple in-memory PubSub for development:
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const MESSAGE_SENT = 'MESSAGE_SENT';
const resolvers = {
Query: {
messages: async (_, { channelId }, { db }) => {
return db.messages.findByChannel(channelId);
},
},
Mutation: {
sendMessage: async (_, { channelId, content, author }, { db }) => {
const message = await db.messages.create({
channelId,
content,
author,
createdAt: new Date().toISOString(),
});
// Publish the event -- all subscribers will receive it
await pubsub.publish(MESSAGE_SENT, {
messageSent: message,
channelId,
});
return message;
},
},
Subscription: {
messageSent: {
subscribe: (_, { channelId }) => {
return pubsub.asyncIterableIterator(MESSAGE_SENT);
},
resolve: (payload) => {
return payload.messageSent;
},
},
},
};
Filtering Events
Not every subscriber should receive every event. The withFilter helper lets you filter events based on subscription arguments:
import { withFilter } from 'graphql-subscriptions';
const resolvers = {
Subscription: {
messageSent: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator(MESSAGE_SENT),
(payload, variables) => {
// Only send the event if the channelId matches
return payload.channelId === variables.channelId;
}
),
},
},
};
Now a client subscribing to messageSent(channelId: "general") will only receive messages posted in the “general” channel, not every message across all channels.
Setting Up the WebSocket Server
Apollo Server does not handle WebSockets directly. You need graphql-ws alongside your HTTP server:
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';
const app = express();
const httpServer = createServer(app);
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Set up WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// Access connection params for auth
const token = ctx.connectionParams?.authorization;
const user = token ? await verifyToken(token) : null;
return { user, db };
},
onConnect: async (ctx) => {
console.log('Client connected');
// Return false to reject the connection
},
onDisconnect: (ctx) => {
console.log('Client disconnected');
},
},
wsServer
);
const server = new ApolloServer({
schema,
plugins: [
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000, () => {
console.log('Server running on http://localhost:4000/graphql');
console.log('WebSocket server running on ws://localhost:4000/graphql');
});
Client-Side Subscriptions
On the client, configure Apollo Client with a split link that routes subscriptions to WebSocket and queries/mutations to HTTP:
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql',
});
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
authorization: localStorage.getItem('token'),
},
})
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
Now use the useSubscription hook in React:
import { useSubscription, gql } from '@apollo/client';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageSent($channelId: ID!) {
messageSent(channelId: $channelId) {
id
content
author
createdAt
}
}
`;
function ChatMessages({ channelId }) {
const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
});
if (loading) return <p>Listening for messages...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div className="new-message">
<strong>{data.messageSent.author}:</strong> {data.messageSent.content}
</div>
);
}
For a complete chat experience, combine useQuery to load existing messages with subscribeToMore to append new ones:
import { useQuery, gql } from '@apollo/client';
const GET_MESSAGES = gql`
query GetMessages($channelId: ID!) {
messages(channelId: $channelId) {
id
content
author
createdAt
}
}
`;
function ChatRoom({ channelId }) {
const { data, loading, subscribeToMore } = useQuery(GET_MESSAGES, {
variables: { channelId },
});
useEffect(() => {
const unsubscribe = subscribeToMore({
document: MESSAGE_SUBSCRIPTION,
variables: { channelId },
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData.data) return prev;
const newMessage = subscriptionData.data.messageSent;
return {
messages: [...prev.messages, newMessage],
};
},
});
return () => unsubscribe();
}, [channelId, subscribeToMore]);
if (loading) return <p>Loading...</p>;
return (
<div>
{data.messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.author}:</strong> {msg.content}
</div>
))}
</div>
);
}
Scaling with Redis PubSub
The in-memory PubSub only works for a single server instance. If you run multiple server replicas behind a load balancer, events published on one instance will not reach subscribers connected to another. Redis PubSub solves this by acting as a shared message broker:
npm install graphql-redis-subscriptions ioredis
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6379'),
retryStrategy: (times) => Math.min(times * 50, 2000),
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
// Use exactly the same way as in-memory PubSub
await pubsub.publish(MESSAGE_SENT, { messageSent: message, channelId });
The resolver code stays identical. Only the PubSub instantiation changes. This means you can start with in-memory PubSub during development and swap in Redis for production with a single configuration change.
Connection Management and Best Practices
WebSocket connections are long-lived and consume server resources. Follow these practices to keep things stable:
Heartbeats and timeouts. The graphql-ws library handles ping/pong automatically, but configure reasonable timeouts:
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
keepAlive: 10000, // Send keep-alive every 10 seconds
retryAttempts: 5,
retryWait: (retries) => new Promise((resolve) =>
setTimeout(resolve, Math.min(1000 * 2 ** retries, 30000))
),
})
);
Authentication on connect. Validate tokens during the WebSocket handshake, not on every message. Reject unauthorized connections in onConnect to avoid wasting resources.
Limit subscription depth. Just like queries, subscriptions can request deeply nested fields. Apply the same query complexity analysis to subscription operations.
Clean up on disconnect. Track active subscriptions and release resources when clients disconnect. The onDisconnect callback in graphql-ws is the right place for this.
When Not to Use Subscriptions
Subscriptions add complexity. Consider alternatives for simpler cases:
- Polling works well when data changes infrequently and near-real-time is acceptable. A query every 5 seconds is simpler than maintaining a WebSocket.
- Server-Sent Events (SSE) provide one-way server-to-client streaming without the overhead of a full WebSocket connection.
- HTTP streaming can work for scenarios where you need a one-time stream of events rather than an ongoing subscription.
Use subscriptions when you need low-latency bidirectional communication, when multiple clients need to stay in sync, and when the data changes frequently enough that polling would be wasteful.
Related articles
- GraphQL GraphQL Subscriptions for Real-Time Data
Build real-time features with GraphQL subscriptions using WebSockets, including setup with Apollo Server and client-side integration.
- GraphQL GraphQL Subscriptions Tutorial
Add real-time data to your GraphQL API with subscriptions. Learn the transport, pubsub patterns, and a working Apollo example.
- GraphQL GraphQL Subscriptions with Redis
How to build GraphQL subscriptions that survive multiple server instances by using Redis as the pub/sub backbone. Covers setup, scaling, and the failure modes nobody warns you about.
- Django Django Channels: Real-Time WebSockets
Add real-time WebSocket support to Django with Channels — consumers, routing, channel layers, groups, and building a live chat.