Real-Time Data with GraphQL WebSocket Subscriptions
Implement GraphQL subscriptions over WebSockets with Apollo Server and PubSub for real-time updates pushed to connected clients
GraphQL subscriptions deliver data to clients in real time using a persistent WebSocket connection. Unlike queries and mutations, which follow a request-response cycle, subscriptions keep the connection open and push updates when server-side events occur. The code below implements subscriptions with Apollo Server’s PubSub engine and a WebSocket gateway.
When to Use This
- Live dashboards or activity feeds that update as data changes
- Chat applications where messages appear instantly
- Collaborative editing with cursor presence or field-level updates
- Any scenario where polling is too slow or wasteful
Prerequisites
- Node.js 18+ with Apollo Server installed
- A WebSocket-capable HTTP server (
wspackage or@nestjs/websockets)
Solution
1. Install Dependencies
npm install @apollo/server graphql-ws graphql ws
2. Define the Subscription Schema
// schema.ts
import gql from 'graphql-tag';
export const typeDefs = gql`
type Post {
id: ID!
title: String!
content: String!
authorId: ID!
createdAt: String!
}
type Query {
posts: [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post!
}
type Subscription {
postCreated: Post!
postUpdated(id: ID!): Post!
}
`;
3. Set Up PubSub and Resolvers
// resolvers.ts
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const POST_CREATED = 'POST_CREATED';
const POST_UPDATED = 'POST_UPDATED';
export const resolvers = {
Query: {
posts: (_: unknown, __: unknown, ctx: Context) =>
ctx.db.posts.findMany(),
},
Mutation: {
createPost: async (_: unknown, args: { title: string; content: string }, ctx: Context) => {
const post = await ctx.db.posts.create({
...args,
authorId: ctx.user.id,
createdAt: new Date().toISOString(),
});
pubsub.publish(POST_CREATED, { postCreated: post });
return post;
},
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED]),
},
postUpdated: {
subscribe: (_: unknown, { id }: { id: string }) =>
pubsub.asyncIterator([{ topic: POST_UPDATED, payload: { id } }]),
},
},
};
export { pubsub, POST_CREATED, POST_UPDATED };
4. Start the HTTP and WebSocket Servers
// server.ts
import { ApolloServer } from '@apollo/server';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import http from 'http';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const httpServer = http.createServer();
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
if (!user) throw new Error('Unauthorized');
return createContext(user);
},
},
wsServer
);
const apolloServer = new ApolloServer({ schema });
await apolloServer.start();
httpServer.listen(4000, () => {
console.log('Server ready at http://localhost:4000/graphql');
});
5. Client-Side Subscription
// client.ts
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
authorization: `Bearer ${getToken()}`,
},
})
);
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
const POST_CREATED_SUBSCRIPTION = gql`
subscription OnPostCreated {
postCreated {
id
title
content
createdAt
}
}
`;
client.subscribe({ query: POST_CREATED_SUBSCRIPTION }).subscribe({
next: ({ data }) => {
console.log('New post:', data.postCreated);
},
error: (err) => console.error('Subscription error:', err),
});
How It Works
- PubSub is an in-memory event emitter.
pubsub.publish(topic, payload)notifies all active iterators for that topic. asyncIteratorwraps the PubSub stream into an async iterable that GraphQL’s execution engine consumes, yielding each published event to the subscriber.graphql-wshandles the WebSocket protocol — the subscription query is sent over the socket, and the server pushes events as they are published.splitlink on the client routes subscriptions to the WebSocket link and queries/mutations to the HTTP link, so a singleApolloClienthandles both.
Variants
Redis PubSub for Multi-Instance
When running multiple server instances, use graphql-redis-subscriptions to share events across processes:
npm install graphql-redis-subscriptions ioredis
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const pubsub = new RedisPubSub({
publisher: new Redis('redis://localhost:6379'),
subscriber: new Redis('redis://localhost:6379'),
});
Filtered Subscriptions
Filter events so clients only receive relevant updates:
Subscription: {
postCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator([POST_CREATED]),
(payload, variables, ctx) => {
return payload.postCreated.authorId === ctx.user.id;
}
),
},
},
Subscription Lifecycle Hooks
Track connection and disconnection for cleanup:
const serverCleanup = useServer(
{
schema,
onConnect: async (ctx) => {
console.log('Client connected:', ctx.connectionParams);
},
onDisconnect: (ctx, code, reason) => {
console.log('Client disconnected:', code, reason);
},
},
wsServer
);
Best Practices
-
For a deeper guide, see GraphQL Federation in Production.
-
Authenticate on connection — validate the token in
contextwhen the WebSocket opens, not per message -
Use Redis PubSub in production — in-memory PubSub does not share events across server instances
-
Filter events server-side — use
withFilterto avoid sending irrelevant data to each client -
Close iterators on disconnect —
graphql-wshandles this automatically, but custom implementations must clean up
Common Mistakes
- Using in-memory PubSub in a cluster — events published on one instance never reach subscribers on another
- Forgetting to split the client link — without
split, subscriptions go over HTTP and fail - Not handling reconnection — WebSocket connections drop; configure
retryAttemptsandreconnectingon the client - Publishing sensitive data — the subscription event goes to every subscriber on that topic; filter by user or permission
Troubleshooting
- Query returns null unexpectedly: verify resolvers, data loaders, and authorization. Check for nullable fields that fail silently.
- N+1 query performance issue: use DataLoader or equivalent batching. Inspect resolver execution traces.
- Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
- Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
- Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
Are subscriptions supported over HTTP/2?
WebSocket is the standard transport. SSE (Server-Sent Events) works for one-way updates but cannot handle the full GraphQL subscription protocol.
How many concurrent subscriptions can one server handle?
Thousands per instance with graphql-ws. Use Redis PubSub and horizontal scaling for higher loads.
Should I use subscriptions or polling for live data?
Subscriptions are better for frequent, server-pushed updates. Polling is simpler for low-frequency data or when WebSockets are not available.
How do I test subscriptions?
Use graphql-ws client in your test suite to open a real WebSocket connection and assert on received events. Mock PubSub for unit tests.
Related Resources
Build a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
RecipeBatch and Cache Database Queries with GraphQL DataLoader
Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers
RecipeBuild Real-Time APIs with WebSockets on Serverless
How to implement bidirectional real-time communication using WebSockets with AWS API Gateway, Lambda, DynamoDB, and what works in connection management.
RecipeSet Up a GraphQL Federation Gateway with Apollo
Compose multiple GraphQL services into a single federated supergraph using Apollo Federation and a gateway that routes queries across subgraphs