Understanding GraphQL Subscriptions
GraphQL subscriptions represent the third fundamental operation type in the GraphQL specification, sitting alongside queries and mutations. While queries fetch data and mutations modify data, subscriptions are designed for long-lived, real-time data delivery. They allow a client to open a persistent connection to the server and receive automatic updates whenever specific events occur that match the subscription's criteria.
At their core, subscriptions implement the publish-subscribe pattern. A client "subscribes" to a particular topic or event on the server. When the server publishes data to that topic, all subscribed clients receive the update in real time. This is fundamentally different from polling or repeatedly executing queries — the server pushes data to the client only when there is something new to deliver.
The Three Core GraphQL Operations
To understand where subscriptions fit, let's quickly contrast the three operation types:
- Queries — Request-response pattern. The client asks for data, the server responds once with the current state. Think of it as "pull" based data fetching.
- Mutations — Request-response pattern with side effects. The client sends a change, the server applies it and returns the result. Also "pull" based, but triggers state changes.
- Subscriptions — Persistent streaming pattern. The client expresses interest in future events, and the server streams updates as they occur. This is "push" based data delivery.
Subscriptions are defined in your GraphQL schema using the Subscription type, and they must return a single root field (unlike queries which can fetch multiple root fields in one operation). The server uses a PubSub (Publish-Subscribe) engine to manage event channels, and each subscription resolver returns an AsyncIterator that yields values over time.
Why GraphQL Subscriptions Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Modern applications increasingly demand real-time capabilities. Users expect to see changes instantly — whether it's a new message in a chat, a stock price update, or a collaborative document edit. GraphQL subscriptions solve this elegantly by extending the same type-safe, schema-driven approach you already use for queries and mutations into the real-time domain. This means:
- Single data layer — Your entire data graph (queries, mutations, and subscriptions) lives in one schema, one endpoint, and shares the same type system.
- Strong typing for real-time data — Subscription payloads are validated against your schema just like query responses, eliminating a whole class of runtime errors.
- Unified tooling — The same developer tools (GraphQL playgrounds, code generators, devtools) work across all three operation types.
- Reduced complexity — Instead of maintaining a separate WebSocket API with its own serialization and validation logic, you extend your existing GraphQL endpoint.
Real-World Use Cases
- Chat and messaging applications — Instantly deliver new messages to all participants in a conversation.
- Live dashboards and analytics — Push updated metrics, charts, and KPIs to admin panels without page refreshes.
- Collaborative editing — Sync document changes, cursor positions, and comments across multiple users.
- E-commerce inventory tracking — Notify users when out-of-stock items become available.
- Sports scores and live events — Stream score updates, play-by-play commentary, and event timelines.
- IoT device monitoring — Receive sensor readings, status changes, and alerts from connected devices in real time.
- Auction and bidding platforms — Push bid updates to all participants to maintain competitive tension.
The Transport Layer: WebSockets and SSE
GraphQL subscriptions require a persistent, bidirectional (or at least server-to-client streaming) communication channel. The two primary transport mechanisms are:
- WebSockets — The most common choice. WebSockets provide full-duplex communication over a single TCP connection. The
graphql-wsprotocol (the modern standard) defines a structured message flow specifically for GraphQL operations over WebSockets. - Server-Sent Events (SSE) — A simpler, HTTP-based unidirectional streaming mechanism. SSE works over standard HTTP connections and can be easier to deploy through proxies and load balancers. Libraries like
graphql-sseand GraphQL Yoga support SSE natively.
The ecosystem has converged on the graphql-ws protocol (maintained by the GraphQL community) as the standard for WebSocket-based GraphQL subscriptions. It replaced the older subscriptions-transport-ws protocol (which was used by Apollo Server v3 and earlier). The new protocol is cleaner, more secure, and handles connection management more robustly. Throughout this tutorial, we'll use graphql-ws.
Server-Side Implementation: A Complete Walkthrough
We'll build a complete subscription-enabled GraphQL server using Apollo Server v4, Express, and the graphql-ws WebSocket transport. The example models a simple real-time chat where clients can post messages and subscribe to new messages as they arrive.
Step 1: Define the Schema with Subscription Types
Your GraphQL schema must include a Subscription type. Each field on this type represents an event that clients can subscribe to. Here's the schema for our chat example:
type Message {
id: ID!
content: String!
author: String!
createdAt: String!
}
type Query {
messages: [Message!]!
}
type Mutation {
postMessage(author: String!, content: String!): Message!
}
type Subscription {
messageAdded: Message!
}
Notice that the Subscription type defines messageAdded which returns a single Message!. When a mutation creates a new message, the subscription will push that new message to all listening clients.
Step 2: Set Up the PubSub System
A PubSub engine is the event bus that decouples your mutation logic from subscription delivery. The graphql-subscriptions package provides an in-memory PubSub implementation perfect for development and single-server setups:
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
// Define event channel names as constants to avoid typos
const MESSAGE_ADDED = 'MESSAGE_ADDED';
const USER_TYPING = 'USER_TYPING';
The PubSub class offers two critical methods:
pubsub.publish(channelName, payload)— Emits an event to a channel with a payload object.pubsub.asyncIterator([channelNames])— Returns an async iterator that yields events from the specified channels. Subscription resolvers return this iterator.
Step 3: Write Subscription Resolvers
Subscription resolvers differ from query and mutation resolvers. Instead of returning data directly, they return an async iterator (or an object with a subscribe method that returns one). The server then iterates over this source, sending each yielded value to the client:
let messages = [];
let idCounter = 1;
const resolvers = {
Query: {
messages: () => messages,
},
Mutation: {
postMessage: (_, { author, content }) => {
const message = {
id: String(idCounter++),
content,
author,
createdAt: new Date().toISOString(),
};
messages.push(message);
// Publish to the PubSub channel — this triggers the subscription
pubsub.publish(MESSAGE_ADDED, { messageAdded: message });
return message;
},
},
Subscription: {
messageAdded: {
// The subscribe function returns an AsyncIterator
subscribe: () => pubsub.asyncIterator([MESSAGE_ADDED]),
},
},
};
Key points about the subscription resolver:
- The
subscribefunction is called once when a client initiates the subscription. - The returned
AsyncIteratoryields payloads that match the channel's published data. - The payload object key (
messageAdded) must match the subscription field name exactly. - You can optionally provide a
resolvefunction alongsidesubscribeto transform the payload before it reaches the client.
Step 4: Wire Up Apollo Server with WebSocket Transport
Apollo Server v4 does not handle WebSocket connections internally. Instead, you create an HTTP server, attach Apollo as middleware for HTTP requests, and set up a separate WebSocket server on the same HTTP server instance for subscription traffic. Here is the complete server implementation:
const express = require('express');
const { createServer } = require('http');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { ApolloServerPluginDrainHttpServer } = require('@apollo/server/plugin/drainHttpServer');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { PubSub } = require('graphql-subscriptions');
const cors = require('cors');
const bodyParser = require('body-parser');
// ---------- PubSub and Data ----------
const pubsub = new PubSub();
const MESSAGE_ADDED = 'MESSAGE_ADDED';
let messages = [];
let idCounter = 1;
// ---------- Schema ----------
const typeDefs = `
type Message {
id: ID!
content: String!
author: String!
createdAt: String!
}
type Query {
messages: [Message!]!
}
type Mutation {
postMessage(author: String!, content: String!): Message!
}
type Subscription {
messageAdded: Message!
}
`;
// ---------- Resolvers ----------
const resolvers = {
Query: {
messages: () => messages,
},
Mutation: {
postMessage: (_, { author, content }) => {
const message = {
id: String(idCounter++),
content,
author,
createdAt: new Date().toISOString(),
};
messages.push(message);
pubsub.publish(MESSAGE_ADDED, { messageAdded: message });
return message;
},
},
Subscription: {
messageAdded: {
subscribe: () => pubsub.asyncIterator([MESSAGE_ADDED]),
},
},
};
// ---------- Main Server Setup ----------
async function main() {
const app = express();
const httpServer = createServer(app);
// Create the WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql', // same path as the HTTP endpoint
});
// Build the executable schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Pass the schema to the WebSocket server handler
const serverCleanup = useServer({ schema }, wsServer);
//