Introduction
Building real-time features—live feeds, instant notifications, collaborative editing—requires persistent connections between client and server. GraphQL Subscriptions provide a first-class way to implement these features while maintaining the type safety and schema integration that makes GraphQL powerful.
In this guide, we'll implement GraphQL subscriptions with Apollo Server 4 and the modern graphql-ws protocol. We'll cover everything from basic setup to production scaling patterns, including authentication, filtering, and distributed PubSub systems.
Understanding GraphQL Subscriptions: Core Concepts
The Subscription Lifecycle
A GraphQL subscription follows a distinct lifecycle that differs from queries and mutations. While queries and mutations are request-response operations over HTTP, subscriptions are long-lived operations over WebSocket:
- Connection: Client opens a WebSocket connection and sends
connection_init - Authentication: Server validates credentials and sends
connection_ack - Subscribe: Client sends a subscription operation
- Execute: Server registers the subscription and begins listening for events
- Push: When events occur, server executes the subscription resolver and pushes results
- Complete: Either client unsubscribes or the server terminates the subscription
This lifecycle means your server must handle both HTTP (for queries/mutations) and WebSocket (for subscriptions) traffic, often on the same port.
The graphql-ws Protocol
The graphql-ws library implements the WebSocket subprotocol for GraphQL. It replaces the older subscriptions-transport-ws with a more secure, performant, and specification-compliant implementation:
// Messages exchanged between client and server
// Client → Server
{ type: "connection_init", payload?: Record<string, unknown> }
{ type: "subscribe", id: string, payload: { query: string, variables?: object } }
{ type: "complete", id: string }
// Server → Client
{ type: "connection_ack" }
{ type: "next", id: string, payload: { data: object } }
{ type: "error", id: string, payload: Array<{ message: string }> }
{ type: "complete", id: string }PubSub Systems
The PubSub (Publish-Subscribe) pattern is the backbone of subscription event delivery. When a mutation modifies data, it publishes an event to a topic. All subscriptions listening to that topic receive the event and execute their resolvers.
For single-server development, the built-in PubSub from graphql-subscriptions works fine. For production with multiple server instances, you need a distributed PubSub backed by Redis, NATS, or Kafka to ensure events reach all servers.
Architecture and Design Patterns
Dual-Transport Architecture
Apollo Server supports both HTTP and WebSocket on the same server. HTTP handles queries and mutations, while WebSocket handles subscriptions:
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import express from "express";
import { createServer } from "http";
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({ path: "/graphql" });
const wsServerCleanup = useServer({ schema }, wsServer);
// Apollo Server for queries/mutations
const apolloServer = new ApolloServer({
schema,
plugins: [{
async serverWillStart() {
return {
async drainServer() {
await wsServerCleanup.dispose();
},
};
},
}],
});
// Express app
const app = express();
const httpServer = createServer(app);
await apolloServer.start();
app.use("/graphql", express.json(), expressMiddleware(apolloServer));
httpServer.listen(4000);Event-Driven Resolver Pattern
Separate event publishing (in mutations) from event consumption (in subscriptions) using a shared PubSub instance:
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const resolvers = {
Mutation: {
createComment: async (_, { input }, { db, user }) => {
const comment = await db.comment.create({
data: { ...input, authorId: user.id },
include: { author: true },
});
// Publish event for real-time subscribers
pubsub.publish("COMMENT_CREATED", {
commentCreated: comment,
postId: input.postId,
});
return comment;
},
},
Subscription: {
commentCreated: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator("COMMENT_CREATED"),
(payload, variables) => payload.postId === variables.postId
),
resolve: (payload) => payload.commentCreated,
},
},
};Authentication Context Pattern
Authenticate WebSocket connections once during connection_init and store the user in the connection context:
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// Authenticate during connection_init
const token = ctx.connectionParams?.authToken as string;
if (!token) {
throw new Error("Authentication required");
}
const user = await verifyToken(token);
if (!user) {
throw new Error("Invalid token");
}
return { user, pubsub, db };
},
},
wsServer
);Step-by-Step Implementation
Project Setup
Install the required packages:
npm install @apollo/server express graphql graphql-ws ws \
graphql-subscriptions @graphql-tools/schema \
@apollo/server express
npm install -D @types/ws @types/expressDefining the Schema
Create a schema with queries, mutations, and subscriptions:
scalar DateTime
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
}
type User {
id: ID!
name: String!
avatar: String
}
type Query {
post(id: ID!): Post
posts(limit: Int, offset: Int): [Post!]!
}
type Mutation {
createComment(input: CreateCommentInput!): Comment!
likePost(postId: ID!): Post!
}
type Subscription {
commentCreated(postId: ID!): Comment!
postLiked(postId: ID!): Post!
newPost: Post!
}
input CreateCommentInput {
postId: ID!
content: String!
}Implementing the Server
Create a complete server with all three transports:
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { PubSub, withFilter } from "graphql-subscriptions";
import express from "express";
import { createServer } from "http";
const pubsub = new PubSub();
// Resolvers
const resolvers = {
Query: {
post: async (_, { id }, { db }) => db.post.findUnique({ where: { id } }),
posts: async (_, args, { db }) => db.post.findMany(args),
},
Post: {
author: (post, _, { db }) => db.user.findUnique({ where: { id: post.authorId } }),
comments: (post, _, { db }) => db.comment.findMany({ where: { postId: post.id } }),
},
Mutation: {
createComment: async (_, { input }, { user, db }) => {
const comment = await db.comment.create({
data: { ...input, authorId: user.id },
include: { author: true },
});
pubsub.publish("COMMENT_CREATED", {
commentCreated: comment,
postId: input.postId,
});
return comment;
},
likePost: async (_, { postId }, { user, db }) => {
await db.postLike.create({
data: { postId, userId: user.id },
});
const post = await db.post.findUnique({ where: { id: postId } });
pubsub.publish("POST_LIKED", { postLiked: post, postId });
return post;
},
},
Subscription: {
commentCreated: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator("COMMENT_CREATED"),
(payload, variables) => payload.postId === variables.postId
),
resolve: (payload) => payload.commentCreated,
},
postLiked: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator("POST_LIKED"),
(payload, variables) => payload.postId === variables.postId
),
resolve: (payload) => payload.postLiked,
},
newPost: {
subscribe: () => pubsub.asyncIterableIterator("NEW_POST"),
resolve: (payload) => payload.newPost,
},
},
};
// Schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket server
const wsServer = new WebSocketServer({ path: "/graphql" });
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authToken as string;
const user = token ? await verifyToken(token) : null;
return { user, pubsub, db };
},
},
wsServer
);
// Apollo Server
const apolloServer = new ApolloServer({
schema,
plugins: [{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
}],
});
// Express setup
const app = express();
const httpServer = createServer(app);
await apolloServer.start();
app.use("/graphql", express.json(), expressMiddleware(apolloServer, {
context: async ({ req }) => ({
user: await verifyToken(req.headers.authorization),
pubsub,
db,
}),
}));
httpServer.listen(4000, () => {
console.log("Server running at http://localhost:4000/graphql");
console.log("Subscriptions at ws://localhost:4000/graphql");
});Client-Side Implementation
Set up Apollo Client with both HTTP and WebSocket links:
import { ApolloClient, InMemoryCache, HttpLink, split } from "@apollo/client";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import { createClient } from "graphql-ws";
// HTTP link for queries and mutations
const httpLink = new HttpLink({
uri: "http://localhost:4000/graphql",
});
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: "ws://localhost:4000/graphql",
connectionParams: () => ({
authToken: localStorage.getItem("token"),
}),
retryAttempts: Infinity,
shouldRetry: () => true,
})
);
// Split traffic based on operation type
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(),
});Using Subscriptions in React Components
import { useSubscription, useMutation, gql } from "@apollo/client";
import { useState } from "react";
const COMMENT_CREATED = gql`
subscription OnCommentCreated($postId: ID!) {
commentCreated(postId: $postId) {
id
content
author { id name avatar }
createdAt
}
}
`;
const CREATE_COMMENT = gql`
mutation CreateComment($input: CreateCommentInput!) {
createComment(input: $input) {
id
content
author { id name }
}
}
`;
function PostComments({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
useSubscription(COMMENT_CREATED, {
variables: { postId },
onData: ({ data }) => {
setComments((prev) => [...prev, data.data.commentCreated]);
},
});
const [createComment] = useMutation(CREATE_COMMENT);
return (
<div>
<CommentList comments={comments} />
<CommentForm
onSubmit={(content) =>
createComment({ variables: { input: { postId, content } } })
}
/>
</div>
);
}Real-World Use Cases
Live Comment System
A blog platform uses subscriptions to display new comments in real-time. When a user posts a comment via mutation, the subscription pushes it to all readers viewing that post. The system uses post-level filtering so users only receive comments for posts they're viewing.
Real-Time Collaboration
A project management tool uses subscriptions for live updates to task boards. When one user moves a task, all other users see the change instantly. Presence subscriptions show who's currently viewing each board.
Live Sports Scores
A sports app subscribes to score updates for games in progress. The subscription includes game-level filtering and delivers score changes, play-by-play updates, and statistics in real-time. Clients maintain local state that's updated incrementally as events arrive.
Monitoring Dashboard
A DevOps dashboard subscribes to server metrics, deployment status, and alert notifications. Each widget subscribes to its specific metric type, and the server aggregates high-frequency data points before pushing them to clients.
Best Practices for Production
-
Use
graphql-wsoversubscriptions-transport-ws: The newer protocol is more secure (no introspection over WebSocket by default) and performant. -
Authenticate at connection level: Verify credentials once during
connection_initand store in context. Don't re-authenticate on every subscription. -
Use distributed PubSub for multi-instance deployments: The in-memory PubSub doesn't share events between processes. Use Redis:
import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
const pubsub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL),
});- Implement connection keepalive: Prevent proxy timeouts and detect dead connections:
const wsServer = new WebSocketServer({
path: "/graphql",
});
useServer({
schema,
// Send keepalive every 30 seconds
onComplete: () => { /* cleanup */ },
}, wsServer);-
Filter subscriptions at the server level: Use
withFilterto ensure clients only receive events they're authorized to see and have subscribed to. -
Rate-limit subscription events: For high-frequency events, implement server-side batching to avoid overwhelming clients:
// Debounce high-frequency events
import { debounce } from "lodash";
const batchPublish = debounce((events) => {
pubsub.publish("BATCH_UPDATE", { events });
}, 100);- Implement graceful shutdown: Drain WebSocket connections before shutting down the server:
process.on("SIGTERM", async () => {
await serverCleanup.dispose();
await apolloServer.stop();
httpServer.close();
});- Monitor subscription metrics: Track active connections, message rates, and error rates to identify issues before they affect users.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using in-memory PubSub in production | Events lost between server instances | Use Redis or NATS PubSub |
| Missing authentication on WebSocket | Unauthorized access to real-time data | Verify tokens in connection_init |
| No connection keepalive | Proxy timeouts, zombie connections | Implement ping/pong at 30s intervals |
| Unbounded subscription growth | Memory leaks, server overload | Set max subscriptions per connection |
| Client not handling reconnection | Lost updates after disconnect | Implement reconnection with exponential backoff |
Performance Optimization
Optimize subscription performance by batching events, using efficient serialization, and minimizing resolver execution:
// Use DataLoader in subscription resolvers
const resolvers = {
Subscription: {
commentCreated: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator("COMMENT_CREATED"),
(payload, variables) => payload.postId === variables.postId
),
resolve: async (payload, _, { loaders }) => {
// Batch author loading
const author = await loaders.user.load(payload.commentCreated.authorId);
return { ...payload.commentCreated, author };
},
},
},
};Comparison with Alternatives
| Feature | GraphQL Subscriptions | WebSocket (raw) | Server-Sent Events | Long Polling |
|---|---|---|---|---|
| Type safety | Full | None | None | Full |
| Schema integration | Yes | No | No | Yes |
| Bidirectional | Yes | Yes | No | No |
| Complexity | Moderate | High | Low | Low |
| Scalability | Good | Good | Good | Poor |
| Browser support | Universal | Universal | Universal | Universal |
| Reconnection | Manual | Manual | Built-in | N/A |
Advanced Patterns
Subscription Deduplication
When multiple clients subscribe to the same topic with identical parameters, deduplicate to reduce server load:
class SubscriptionManager {
private subscriptions = new Map<string, { count: number; iterator: AsyncIterator<any> }>();
subscribe(topic: string, iteratorFactory: () => AsyncIterator<any>) {
if (this.subscriptions.has(topic)) {
this.subscriptions.get(topic)!.count++;
} else {
this.subscriptions.set(topic, { count: 1, iterator: iteratorFactory() });
}
}
unsubscribe(topic: string) {
const sub = this.subscriptions.get(topic);
if (sub) {
sub.count--;
if (sub.count === 0) {
sub.iterator.return?.();
this.subscriptions.delete(topic);
}
}
}
}Subscription-Based Cache Invalidation
Use subscriptions to invalidate Apollo Client cache entries when data changes:
useSubscription(ORDER_UPDATED, {
variables: { orderId },
onData: ({ data }) => {
// Update the cache with new order data
cache.modify({
id: `Order:${orderId}`,
fields: {
status: () => data.orderUpdated.status,
total: () => data.orderUpdated.total,
},
});
},
});Testing Strategies
Test subscriptions using WebSocket test clients and mock PubSub:
import { createClient } from "graphql-ws";
import WebSocket from "ws";
describe("Comment subscription", () => {
it("delivers new comments to subscribers", (done) => {
const client = createClient({
url: "ws://localhost:4000/graphql",
webSocketImpl: WebSocket,
connectionParams: { authToken: testToken },
});
const unsubscribe = client.subscribe(
{
query: `subscription { commentCreated(postId: "1") { id content } }`,
},
{
next: (data) => {
expect(data.data.commentCreated.content).toBe("Great post!");
unsubscribe();
done();
},
error: done,
complete: () => {},
}
);
// Create comment after subscription is active
setTimeout(async () => {
await server.executeOperation({
query: `mutation { createComment(input: { postId: "1", content: "Great post!" }) { id } }`,
});
}, 100);
});
});Subscription Filtering and Authorization
Filtering subscription events at the server level prevents clients from receiving irrelevant data and reduces bandwidth consumption. Implement filtering logic in the subscription resolver by checking event properties against subscription arguments or user permissions before publishing. For example, a chat subscription might filter messages by room ID, while a notification subscription might filter by notification type or severity level.
const resolvers = {
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator('MESSAGE_ADDED'),
(payload, variables, context) => {
const message = payload.messageAdded;
return (
message.roomId === variables.roomId &&
context.user.tenants.includes(message.tenantId)
);
}
),
},
},
};Authorization for subscriptions requires validating the user's permissions at subscription creation time and on each published event. The initial subscription request should verify that the authenticated user has access to the requested resource. Subsequent event filtering should re-validate permissions because user roles may change between subscription creation and event delivery. Store the authenticated user context during the WebSocket handshake and attach it to each subscription for per-event authorization checks.
Scaling Subscriptions with Redis and Message Brokers
Running GraphQL subscriptions across multiple server instances requires a shared message broker to distribute events between servers. Redis Pub/Sub is the most common choice, where each server instance subscribes to relevant Redis channels and forwards events to its connected WebSocket clients. The graphql-redis-subscriptions package replaces the in-memory PubSub with a Redis-backed implementation that works transparently with existing subscription resolvers.
For higher throughput requirements, consider Apache Kafka or NATS as the message broker instead of Redis. Kafka provides durable message storage, consumer groups for horizontal scaling, and exactly-once delivery semantics. NATS offers ultra-low latency messaging with built-in clustering and message replay capabilities. The choice depends on your durability requirements, latency tolerance, and operational expertise with each system.
Deploy subscription servers behind a load balancer that supports WebSocket connection affinity. Sticky sessions ensure that each WebSocket connection routes to the same server for its lifetime, which is required for in-memory PubSub and simplifies Redis PubSub by reducing cross-instance message fan-out. Health checks should verify both HTTP and WebSocket connectivity to detect servers that accept new connections but fail to deliver subscription events.
Future Outlook
GraphQL subscriptions are evolving with the graphql-ws protocol becoming the standard. Future developments include subscription batching (receiving multiple events in a single message), incremental delivery for large payloads, and better integration with edge computing for ultra-low-latency applications. The subscription specification is also being extended to support filtering at the schema level.
Conclusion
GraphQL Subscriptions provide a powerful, type-safe way to build real-time features. The key takeaways are:
- Use
graphql-wsprotocol with Apollo Server 4 for modern, secure WebSocket subscriptions - Authenticate at the connection level during
connection_init - Use distributed PubSub (Redis, NATS) for production multi-instance deployments
- Filter events at the server level using
withFilter - Implement connection keepalive, reconnection, and graceful shutdown for production reliability
Start with the in-memory PubSub for development, then migrate to Redis PubSub for production. Combined with proper authentication and filtering, GraphQL Subscriptions enable real-time features that keep users engaged and informed.