What Are GraphQL Subscriptions?
GraphQL subscriptions provide a way to establish real-time, persistent connections between a client and a GraphQL server. While queries are used to fetch data on demand and mutations are used to modify data, subscriptions allow the server to push updates to clients as soon as specific events occur. They are the GraphQL specification's answer to real-time data delivery.
At their core, subscriptions leverage a persistent transport layer — typically WebSockets — to maintain an open connection. Once a client subscribes to a particular event stream, the server can deliver payloads over that connection whenever the underlying data changes. Unlike polling, where the client repeatedly asks "has anything changed?", subscriptions invert the flow: the server says "something changed, here's the update."
A subscription operation looks structurally similar to a query or mutation in the GraphQL document:
subscription OnNewMessage {
messageCreated(roomId: "lobby") {
id
content
sender {
username
avatarUrl
}
createdAt
}
}
What makes subscriptions unique is the execution model. Rather than returning a single response, the server returns an asynchronous iterator or a stream that yields multiple results over time — one for each event that matches the subscription's selection set.
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 user experiences. Whether it's a chat application, a collaborative document editor, a live dashboard, a sports score tracker, or a financial trading platform, users expect to see changes as they happen, without manual refreshes. GraphQL subscriptions address this need in a way that integrates cleanly with the rest of your GraphQL schema.
Here are the key reasons subscriptions are indispensable:
- Unified schema and type system: Subscriptions live alongside queries and mutations in the same GraphQL schema. There is no separate API surface for real-time events — everything is defined, documented, and typed in one place. This dramatically simplifies both development and client-side consumption.
- Strongly typed event payloads: Unlike generic WebSocket message formats or opaque JSON blobs, subscription responses are validated against the GraphQL schema. Clients get exactly the fields they request, and tools like Apollo Client or Relay can cache and merge subscription results into normalized stores automatically.
- Reduced over-fetching: A subscription's selection set specifies precisely which fields the client needs for each event. If a UI component only cares about an entity's status field, it can subscribe to just that field rather than receiving a bloated payload.
- Elimination of polling: Polling is wasteful in terms of bandwidth, server CPU, and battery life on mobile devices. Subscriptions deliver updates only when there is actually something to deliver.
- Single data layer: Teams avoid maintaining parallel real-time infrastructure (like a separate socket.io service with its own schema) alongside their GraphQL API. Everything flows through the same GraphQL gateway.
How GraphQL Subscriptions Work Under the Hood
Understanding the lifecycle of a subscription is crucial for debugging and optimization. Here's the step-by-step flow:
- Client initiates a subscription: The client sends a GraphQL document containing a subscription operation over a WebSocket connection (or, in newer transports like graphql-ws, over HTTP with a protocol upgrade).
- Server validates and resolves: The server's subscription resolver is invoked. Instead of returning a value directly, it returns an AsyncIterator or a similar reactive stream source.
- Server sets up a source event listener: Behind the scenes, the resolver typically subscribes to an internal pub/sub system, an event emitter, a database change stream (like PostgreSQL NOTIFY, MongoDB change streams, or Redis pub/sub), or a message broker like Kafka.
- Data flows through the iterator: When an event occurs in the source system, the iterator yields a value. The GraphQL engine applies the subscription's selection set to that value, serializes the result, and writes it to the client's WebSocket.
- Client receives updates: The client-side GraphQL library deserializes the payload, merges it into the local cache (if applicable), and notifies the UI layer to re-render affected components.
- Termination: The client can unsubscribe by closing the WebSocket connection or sending a specific "complete" message. The server then cleans up the source listener to prevent memory leaks.
The transport protocol matters significantly. The legacy protocol (subscriptions-transport-ws) used by early Apollo Server versions has been superseded by the graphql-ws protocol, which is now the standard. The newer protocol fixes several design flaws, supports bidirectional acknowledgment of subscription starts and stops, and is more resilient to connection drops.
Setting Up a GraphQL Subscription Server
We'll walk through a complete, production-ready setup using Apollo Server 4 with the graphql-ws WebSocket implementation and a Redis-backed pub/sub layer for horizontal scalability. The example models a simple chat application.
Step 1: Define the Schema
First, define your GraphQL schema using the subscription keyword. The subscription field returns a type — typically the same type you'd fetch via a query, which allows clients to reuse fragments seamlessly.
# schema.graphql
type Message {
id: ID!
content: String!
sender: User!
roomId: String!
createdAt: String!
}
type User {
id: ID!
username: String!
avatarUrl: String
}
type Query {
messages(roomId: String!): [Message!]!
me: User
}
type Mutation {
sendMessage(roomId: String!, content: String!): Message!
joinRoom(roomId: String!): Boolean!
leaveRoom(roomId: String!): Boolean!
}
type Subscription {
messageCreated(roomId: String!): Message!
userJoined(roomId: String!): User!
userLeft(roomId: String!): User!
}
Step 2: Implement a Pub/Sub Abstraction
For a single-server setup, an in-memory EventEmitter works. For production, Redis pub/sub allows multiple server instances to broadcast events across processes. We'll build a generic pub/sub interface so the resolver layer stays decoupled from the transport.
// pubsub.ts
import { Redis } from 'ioredis';
interface PubSubConfig {
redisUrl?: string;
}
export class PubSub {
private redisPublisher: Redis | null = null;
private redisSubscriber: Redis | null = null;
private localListeners: Map void>> = new Map();
constructor(config: PubSubConfig = {}) {
if (config.redisUrl) {
this.redisPublisher = new Redis(config.redisUrl);
this.redisSubscriber = new Redis(config.redisUrl);
this.redisSubscriber.on('message', (channel, message) => {
const listeners = this.localListeners.get(channel);
if (listeners) {
const parsed = JSON.parse(message);
listeners.forEach(cb => cb(parsed));
}
});
}
}
publish(channel: string, payload: any): void {
const stringPayload = JSON.stringify(payload);
// Deliver locally
const listeners = this.localListeners.get(channel);
if (listeners) {
listeners.forEach(cb => cb(payload));
}
// Publish to Redis for other server instances
if (this.redisPublisher) {
this.redisPublisher.publish(channel, stringPayload);
}
}
subscribe(channel: string, callback: (payload: any) => void): () => void {
if (!this.localListeners.has(channel)) {
this.localListeners.set(channel, new Set());
// Subscribe to Redis channel if using Redis
if (this.redisSubscriber) {
this.redisSubscriber.subscribe(channel);
}
}
this.localListeners.get(channel)!.add(callback);
// Return unsubscribe function
return () => {
const listeners = this.localListeners.get(channel);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
this.localListeners.delete(channel);
if (this.redisSubscriber) {
this.redisSubscriber.unsubscribe(channel);
}
}
}
};
}
asyncIterable(channel: string): AsyncIterator {
let pushQueue: T[] = [];
let resolveWait: ((value: IteratorResult) => void) | null = null;
let completed = false;
const unsubscribe = this.subscribe(channel, (payload: T) => {
if (resolveWait) {
resolveWait({ value: payload, done: false });
resolveWait = null;
} else {
pushQueue.push(payload);
}
});
return {
next: async (): Promise> => {
if (completed) return { value: undefined, done: true };
if (pushQueue.length > 0) {
const value = pushQueue.shift()!;
return { value, done: false };
}
return new Promise(resolve => {
resolveWait = resolve;
});
},
return: async (): Promise> => {
completed = true;
unsubscribe();
if (resolveWait) {
resolveWait({ value: undefined, done: true });
resolveWait = null;
}
return { value: undefined, done: true };
},
throw: async (error: any): Promise> => {
completed = true;
unsubscribe();
throw error;
},
[Symbol.asyncIterator]() {
return this;
}
};
}
}
Step 3: Write the Resolvers
Subscription resolvers return an AsyncIterator. The subscribe field on the resolver object is where you set up the event source. The resolve field (optional) can transform the payload before it's sent to the client.
// resolvers.ts
import { PubSub } from './pubsub';
const pubsub = new PubSub({ redisUrl: process.env.REDIS_URL });
const CHAT_ROOM_MESSAGE = 'CHAT_ROOM_MESSAGE';
const CHAT_ROOM_USER_JOINED = 'CHAT_ROOM_USER_JOINED';
const CHAT_ROOM_USER_LEFT = 'CHAT_ROOM_USER_LEFT';
// In-memory store for demonstration
const messagesByRoom: Record = {};
const usersByRoom: Record> = {};
interface Message {
id: string;
content: string;
senderId: string;
roomId: string;
createdAt: string;
}
interface User {
id: string;
username: string;
avatarUrl: string | null;
}
const resolvers = {
Query: {
messages: (_parent: any, args: { roomId: string }) => {
return messagesByRoom[args.roomId] || [];
},
me: (_parent: any, _args: any, context: { userId: string }) => {
return getUserById(context.userId);
}
},
Mutation: {
sendMessage: (_parent: any, args: { roomId: string; content: string }, context: { userId: string }) => {
const message: Message = {
id: generateId(),
content: args.content,
senderId: context.userId,
roomId: args.roomId,
createdAt: new Date().toISOString()
};
if (!messagesByRoom[args.roomId]) {
messagesByRoom[args.roomId] = [];
}
messagesByRoom[args.roomId].push(message);
// Publish event — this triggers subscription deliveries
pubsub.publish(`${CHAT_ROOM_MESSAGE}:${args.roomId}`, message);
return message;
},
joinRoom: (_parent: any, args: { roomId: string }, context: { userId: string }) => {
if (!usersByRoom[args.roomId]) {
usersByRoom[args.roomId] = new Set();
}
const user = getUserById(context.userId);
usersByRoom[args.roomId].add(context.userId);
pubsub.publish(`${CHAT_ROOM_USER_JOINED}:${args.roomId}`, user);
return true;
},
leaveRoom: (_parent: any, args: { roomId: string }, context: { userId: string }) => {
if (usersByRoom[args.roomId]) {
usersByRoom[args.roomId].delete(context.userId);
}
const user = getUserById(context.userId);
pubsub.publish(`${CHAT_ROOM_USER_LEFT}:${args.roomId}`, user);
return true;
}
},
Message: {
sender: (parent: Message) => getUserById(parent.senderId)
},
Subscription: {
messageCreated: {
subscribe: (_parent: any, args: { roomId: string }) => {
return pubsub.asyncIterable(`${CHAT_ROOM_MESSAGE}:${args.roomId}`);
},
resolve: (payload: Message) => payload
},
userJoined: {
subscribe: (_parent: any, args: { roomId: string }) => {
return pubsub.asyncIterable(`${CHAT_ROOM_USER_JOINED}:${args.roomId}`);
},
resolve: (payload: User) => payload
},
userLeft: {
subscribe: (_parent: any, args: { roomId: string }) => {
return pubsub.asyncIterable(`${CHAT_ROOM_USER_LEFT}:${args.roomId}`);
},
resolve: (payload: User) => payload
}
}
};
// Helper functions
let idCounter = 0;
function generateId(): string {
return `msg_${Date.now()}_${++idCounter}`;
}
function getUserById(id: string): User {
// Mock user lookup
return { id, username: `user_${id}`, avatarUrl: null };
}
Step 4: Wire Up the Server with WebSocket Support
Apollo Server 4 uses the @apollo/server package. To enable subscriptions, you need to create a WebSocket server using the ws package and the graphql-ws library, then integrate it with your HTTP server.
// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers';
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
// Create the executable schema once, shared between HTTP and WS servers
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = http.createServer(app);
// Set up the WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// Use graphql-ws to handle the subscription protocol
const serverCleanup = useServer({
schema,
context: async (ctx, _msg, _args) => {
// Extract auth token from connection params
const token = ctx.connectionParams?.authorization as string || '';
const userId = verifyTokenAndGetUserId(token);
return { userId };
}
}, wsServer);
// Create Apollo Server instance
const apolloServer = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
serverCleanup.dispose();
}
};
}
}
],
});
await apolloServer.start();
app.use(
'/graphql',
cors({
origin: ['https://your-app-domain.com'],
credentials: true,
}),
express.json(),
expressMiddleware(apolloServer, {
context: async ({ req }) => {
const token = req.headers.authorization || '';
const userId = verifyTokenAndGetUserId(token.replace('Bearer ', ''));
return { userId };
}
})
);
const PORT = parseInt(process.env.PORT || '4000', 10);
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
console.log(`🚀 WebSocket subscriptions ready at ws://localhost:${PORT}/graphql`);
});
function verifyTokenAndGetUserId(token: string): string {
// Verify JWT or session token here
// Return the authenticated user ID or throw an error
if (!token) return 'anonymous';
// ... JWT verification logic ...
return 'user_123'; // Mock
}
This setup ensures that both HTTP queries/mutations and WebSocket subscriptions are handled on the same path (/graphql), which simplifies client configuration and proxy setup.
Client-Side Subscription Handling
On the client, consuming subscriptions requires a WebSocket-capable GraphQL client. Apollo Client provides first-class support via a split link that routes subscription operations to a WebSocket transport while keeping queries and mutations on HTTP.
Setting Up Apollo Client with Subscriptions
// apollo-client-setup.ts
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';
// HTTP link for queries and mutations
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
headers: {
authorization: `Bearer ${getToken()}`,
},
});
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://api.example.com/graphql',
connectionParams: () => ({
authorization: `Bearer ${getToken()}`,
}),
retryAttempts: Infinity,
retryWait: (attempt) => new Promise(resolve =>
setTimeout(resolve, Math.min(1000 * 2 ** attempt, 30000))
),
on: {
connected: (socket) => {
console.log('WebSocket connected');
},
closed: () => {
console.log('WebSocket closed, will retry');
},
error: (error) => {
console.error('WebSocket error:', error);
}
}
})
);
// Split link routes operations based on 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(),
});
function getToken(): string {
return localStorage.getItem('auth_token') || '';
}
Using the useSubscription Hook in React
Apollo Client's React integration provides a useSubscription hook that manages the subscription lifecycle automatically. It subscribes when the component mounts and unsubscribes on unmount.
// ChatRoom.tsx
import { useSubscription, gql } from '@apollo/client';
const MESSAGE_CREATED_SUBSCRIPTION = gql`
subscription OnNewMessage($roomId: String!) {
messageCreated(roomId: $roomId) {
id
content
sender {
username
avatarUrl
}
createdAt
}
}
`;
const MESSAGE_FRAGMENT = gql`
fragment MessageFields on Message {
id
content
sender {
username
avatarUrl
}
createdAt
}
`;
function ChatRoom({ roomId }: { roomId: string }) {
const { data, loading, error } = useSubscription(
MESSAGE_CREATED_SUBSCRIPTION,
{ variables: { roomId } }
);
const [messages, setMessages] = useState([]);
useEffect(() => {
if (data?.messageCreated) {
setMessages(prev => [...prev, data.messageCreated]);
}
}, [data]);
if (error) {
return Subscription error: {error.message};
}
return (
Chat Room: {roomId}
{messages.map(msg => (
{msg.sender.username}
{msg.content}
))}
{loading && !data && Waiting for first message...
}
);
}
Handling Subscriptions in Vanilla JavaScript
If you're not using a framework, you can use the graphql-ws client directly. This gives you full control over connection management.
// vanilla-subscription.ts
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'wss://api.example.com/graphql',
connectionParams: {
authorization: `Bearer ${token}`,
},
});
// Subscribe to a specific stream
const subscription = client.iterate({
query: `
subscription OnNewMessage($roomId: String!) {
messageCreated(roomId: $roomId) {
id
content
sender { username }
createdAt
}
}
`,
variables: { roomId: 'lobby' },
});
// Async iterator consumption
(async () => {
for await (const event of subscription) {
if (event.data?.messageCreated) {
console.log('New message:', event.data.messageCreated);
appendMessageToUI(event.data.messageCreated);
}
}
})();
// To unsubscribe later:
// subscription.return();
Authentication and Authorization for Subscriptions
Securing subscriptions requires special attention because WebSocket connections don't carry HTTP headers automatically. The graphql-ws protocol supports passing authentication data via connectionParams, which are sent during the WebSocket handshake.
On the server side, the useServer callback receives these connection parameters. You should validate tokens and attach user identity to the context, just as you would for HTTP requests:
// Server-side auth context for WebSocket
const wsServerCleanup = useServer({
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization as string;
if (!token) {
// Reject anonymous connections for authenticated subscriptions
throw new Error('Authentication required');
}
try {
const user = await verifyJwt(token.replace('Bearer ', ''));
return { userId: user.id, roles: user.roles };
} catch {
throw new Error('Invalid token');
}
},
// Optional: filter which subscriptions are available
onSubscribe: async (ctx, msg) => {
const { userId } = ctx.context as any;
// Check if user is allowed to subscribe to this room
const roomId = msg.variables?.roomId as string;
if (roomId && !await isUserMemberOfRoom(userId, roomId)) {
throw new Error('Not a member of this room');
}
return ctx;
},
}, wsServer);
For additional security, consider these measures:
- Token expiry during long-lived connections: If your JWTs have short expiry times, implement a refresh mechanism. The client can send a
connectionParamsupdate or you can issue connection-scoped tokens with longer lifetimes. - Rate limiting subscriptions: Limit the number of concurrent subscriptions per client to prevent abuse. You can implement this in the
onSubscribehook by tracking subscription counts per user ID. - Field-level authorization: Use GraphQL directives or custom middleware to check permissions on individual subscription fields, ensuring users only see data they're authorized to receive.
Advanced Patterns and Best Practices
Pattern 1: Subscription Filters and Dynamic Topics
Rather than creating a separate subscription field for every permutation of arguments, design your pub/sub topics to encode filtering criteria. The resolver's subscribe function then creates a channel name that incorporates the arguments:
// Dynamic topic naming
Subscription: {
documentUpdated: {
subscribe: (_parent: any, args: { documentId: string; sections?: string[] }) => {
const channel = `DOCUMENT_UPDATED:${args.documentId}`;
const iterator = pubsub.asyncIterable(channel);
// If sections filter is provided, wrap the iterator
if (args.sections?.length) {
return filterIterator(iterator, (payload: any) =>
args.sections!.includes(payload.section)
);
}
return iterator;
}
}
}
// Generic async iterator filter utility
function filterIterator(
source: AsyncIterator,
predicate: (value: T) => boolean
): AsyncIterator {
let filteredQueue: T[] = [];
let resolveWait: ((result: IteratorResult) => void) | null = null;
let sourceDone = false;
const processSource = async () => {
while (!sourceDone) {
try {
const result = await source.next();
if (result.done) {
sourceDone = true;
if (resolveWait) {
resolveWait({ value: undefined, done: true });
resolveWait = null;
}
break;
}
if (predicate(result.value)) {
if (resolveWait) {
resolveWait({ value: result.value, done: false });
resolveWait = null;
} else {
filteredQueue.push(result.value);
}
}
} catch (error) {
if (resolveWait) {
resolveWait({ value: undefined, done: true });
resolveWait = null;
}
sourceDone = true;
break;
}
}
};
processSource();
return {
next: async () => {
if (sourceDone && filteredQueue.length === 0) {
return { value: undefined, done: true };
}
if (filteredQueue.length > 0) {
return { value: filteredQueue.shift()!, done: false };
}
return new Promise(resolve => {
resolveWait = resolve;
});
},
return: async () => {
sourceDone = true;
filteredQueue = [];
if (resolveWait) {
resolveWait({ value: undefined, done: true });
resolveWait = null;
}
await source.return?.();
return { value: undefined, done: true };
},
throw: async (error: any) => {
sourceDone = true;
await source.throw?.(error);
throw error;
},
[Symbol.asyncIterator]() { return this; }
};
}
Pattern 2: Live Queries as an Alternative
Some systems implement "live queries" where the server re-executes a query whenever its underlying data changes, streaming incremental results to the client. This is not part of the official GraphQL spec but is gaining traction. Libraries like @graphql-tools/live-query or custom solutions using database change streams can simulate this. However, subscriptions remain the spec-compliant, well-supported approach for most use cases.
Pattern 3: Batching Subscription Updates
If events arrive at high frequency (e.g., cursor movements in a collaborative editor), sending each event individually can overwhelm the network. Implement a debounce or throttle on the server side before publishing:
// Debounced publisher for high-frequency events
class DebouncedPublisher {
private buffers: Map = new Map();
private timers: Map = new Map();
private debounceMs: number;
private pubsub: PubSub;
constructor(pubsub: PubSub, debounceMs: number = 50) {
this.pubsub = pubsub;
this.debounceMs = debounceMs;
}
enqueue(channel: string, payload: any): void {
if (!this.buffers.has(channel)) {
this.buffers.set(channel, []);
}
this.buffers.get(channel)!.push(payload);
if (this.timers.has(channel)) {
clearTimeout(this.timers.get(channel)!);
}
this.timers.set(channel, setTimeout(() => {
const batch = this.buffers.get(channel) || [];
this.buffers.delete(channel);
this.timers.delete(channel);
// Publish the batch as a single event
this.pubsub.publish(channel, { batch, count: batch.length });
}, this.debounceMs));
}
}
Best Practices Summary
- Keep subscription payloads small: Only request fields that the client truly needs for real-time updates. If the full object is large, consider a subscription that returns just IDs, and let the client fetch details via a separate query.
- Design for idempotency: Clients should handle duplicate events gracefully. Network glitches can cause redelivery. Use sequence numbers or timestamps to deduplicate.
- Test subscription cleanup: Verify that unsubscribing (e.g., navigating away from a page) actually closes the WebSocket or sends a complete message. Memory leaks from abandoned iterators are a common pitfall.
- Use separate pub/sub channels per entity: A channel like
POST_UPDATED:postId:123is more efficient than a globalPOST_UPDATEDchannel that requires filtering on every connected client. - Monitor WebSocket connection counts: Each active subscription holds a TCP connection. Have dashboards and alerts for connection spikes, and ensure your infrastructure (load balancers, proxies) is configured for long-lived WebSocket connections.
- Prefer the graphql-ws protocol: It's the modern standard, actively maintained, and fixes subtle issues around subscription completion and error propagation that exist in the older subscriptions-transport-ws.
Error Handling and Reconnection Strategies
Robust subscription systems must gracefully handle errors at multiple levels: transport errors, subscription-specific errors, and data resolution errors.
Server-Side Error Handling
When an error occurs during subscription execution, the server should send an error payload to the client without necessarily terminating the entire subscription (unless the error is fatal). In graphql-ws, you can emit errors on the iterator:
// Enhanced async iterator with error emission
function resilientAsyncIterator(
channel: string,
pubsub: PubSub
): AsyncIterator {
let errored = false;
let errorValue: Error | null = null;
const rawIterator = pubsub.asyncIterable(channel);
return {
next: async () => {
if (errored) throw errorValue;
try {
return await rawIterator.next();
} catch (err) {
errored = true;
errorValue = err instanceof Error ? err : new Error(String(err));
throw errorValue;
}
},
return: async () => {
return rawIterator.return!();
},
throw: async (error: any) => {
return rawIterator.throw!(error);
},
[Symbol.asyncIterator]() { return this; }
};
}
Client-Side Reconnection
The graphql-ws client library has built-in retry logic. Configure it to handle transient network failures gracefully. When the connection drops, the client will attempt to reconnect with exponential backoff. Once reconnected, it automatically re-subscribes to all active subscriptions.
// Resilient client configuration
const wsClient = createClient({
url: 'wss://api.example.com/graphql',
connectionParams: { authorization: `Bearer ${getToken()}` },
retryAttempts: Infinity,
retryWait: (attempt) => {
// Exponential backoff with jitter
const base = Math.min(1000 * 2 ** attempt, 30000);
const jitter = Math.random() * 1000;
return new Promise(resolve => setTimeout(resolve, base + jitter));
},
isFatalConnectionProblem: (error) => {
// Only treat 4xx auth errors as fatal (don't retry)
if (error instanceof Error && error.message.includes('401')) {
return true;
}
return false;
},
on: {
connected: () => {
console.log('Reconnected, subscriptions resumed');
},
connecting: () => {
console.log('Attempting to reconnect...');
},
closed: () => {
console.log('Connection closed');
},
},
});
On the application level, you should also handle the case where a subscription fails due to authorization changes (e.g., user logs out). Listen for the error callback on useSubscription and present appropriate UI feedback:
const { data, error } = useSubscription(SUBSCRIPTION_DOC, { variables });
if (error) {
if (error.message.includes('Not authorized')) {
return
🚀 Need a reliable AI agent for your project?
Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.
Get Started — $23.99/mo