Skip to content

GraphQL Subscriptions for Real-Time Features

A hands-on guide to GraphQL subscriptions for real-time updates: WebSocket transport, subscription resolvers, filtering and scaling considerations.

4 min read
GraphQL subscription flow diagram showing server pushing real-time updates to connected clients

REST APIs follow a request-response pattern — the client asks, the server answers. For real-time features like live notifications, chat messages, or dashboard updates, this model breaks down. You either poll repeatedly (wasting bandwidth and increasing latency) or you use a push mechanism. GraphQL subscriptions give you push semantics with the same type safety and schema-driven design as queries and mutations.

Subscriptions use WebSocket connections under the hood. The client declares what events it cares about, and the server pushes data through the open connection whenever those events occur.

Setting Up the Subscription Server

Most GraphQL servers support subscriptions through the graphql-ws protocol. Here is a setup using Apollo Server with Express:

tstypescript
import { createServer } from 'http';
import express from 'express';
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';
 
const app = express();
const httpServer = createServer(app);
 
// Create WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});
 
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// Set up graphql-ws handler
const serverCleanup = useServer({ schema }, wsServer);
 
const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});
 
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);

The key detail: HTTP handles queries and mutations while WebSocket handles subscriptions, both on the same /graphql endpoint. The graphql-ws library manages the WebSocket lifecycle — connection handshake, message framing, and keep-alive pings.

Defining Subscription Types

Subscriptions are defined in your schema alongside queries and mutations. They describe what events clients can subscribe to and what data shape they receive.

graphqlgraphql
type Subscription {
  # Simple subscription — receive every new message
  messageCreated(channelId: ID!): Message!
 
  # Filtered subscription — only specific order status changes
  orderStatusChanged(orderId: ID!): OrderStatusUpdate!
 
  # Broadcast subscription — all connected clients receive this
  systemNotification: SystemNotification!
}
 
type Message {
  id: ID!
  channelId: ID!
  author: User!
  content: String!
  createdAt: DateTime!
}
 
type OrderStatusUpdate {
  orderId: ID!
  previousStatus: OrderStatus!
  newStatus: OrderStatus!
  updatedAt: DateTime!
}
 
type SystemNotification {
  id: ID!
  level: NotificationLevel!
  message: String!
  timestamp: DateTime!
}
 
enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}
 
enum NotificationLevel {
  INFO
  WARNING
  CRITICAL
}

Implementing Subscription Resolvers

Subscription resolvers use an AsyncIterator pattern. You publish events from your business logic and the resolver filters and delivers them to the right subscribers.

tstypescript
import { PubSub, withFilter } from 'graphql-subscriptions';
 
const pubsub = new PubSub();
 
// Event name constants
const EVENTS = {
  MESSAGE_CREATED: 'MESSAGE_CREATED',
  ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
  SYSTEM_NOTIFICATION: 'SYSTEM_NOTIFICATION',
} as const;
 
const resolvers = {
  Subscription: {
    messageCreated: {
      // withFilter ensures clients only receive messages
      // for the channel they subscribed to
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.MESSAGE_CREATED),
        (payload, variables) => {
          return payload.messageCreated.channelId === variables.channelId;
        }
      ),
    },
 
    orderStatusChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.ORDER_STATUS_CHANGED),
        (payload, variables) => {
          return payload.orderStatusChanged.orderId === variables.orderId;
        }
      ),
    },
 
    systemNotification: {
      // No filter — all subscribers receive all notifications
      subscribe: () =>
        pubsub.asyncIterableIterator(EVENTS.SYSTEM_NOTIFICATION),
    },
  },
 
  Mutation: {
    sendMessage: async (_: unknown, args: { channelId: string; content: string }, context: { userId: string }) => {
      const message = await createMessage({
        channelId: args.channelId,
        authorId: context.userId,
        content: args.content,
      });
 
      // Publish event — all matching subscribers receive it
      await pubsub.publish(EVENTS.MESSAGE_CREATED, {
        messageCreated: message,
      });
 
      return message;
    },
  },
};
tstypescript
// ❌ Publishing without the correct payload shape
await pubsub.publish('MESSAGE_CREATED', { message: newMessage });
// Resolver expects payload.messageCreated, not payload.message
// Subscribers receive null/undefined — silent failure
 
// ✅ Payload key must match the subscription field name
await pubsub.publish('MESSAGE_CREATED', {
  messageCreated: newMessage,  // Matches subscription field name exactly
});

Client-Side Subscription Handling

On the client, subscriptions integrate with your existing GraphQL client. Here is a React component using Apollo Client:

tstypescript
import { useSubscription, gql } from '@apollo/client';
 
const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageCreated($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      content
      author {
        id
        name
        avatar
      }
      createdAt
    }
  }
`;
 
function ChatMessages({ channelId }: { channelId: string }) {
  const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
    onData: ({ data: subscriptionData }) => {
      // Optional: handle each incoming message
      const message = subscriptionData.data?.messageCreated;
      if (message) {
        playNotificationSound();
      }
    },
  });
 
  if (error) return <div>Connection error: {error.message}</div>;
  if (loading) return <div>Connecting to channel...</div>;
 
  const newMessage = data?.messageCreated;
  return newMessage ? (
    <div className="message">
      <strong>{newMessage.author.name}:</strong> {newMessage.content}
    </div>
  ) : null;
}

Scaling Subscriptions in Production

The in-memory PubSub from graphql-subscriptions works for a single server instance. In production with multiple server instances behind a load balancer, you need a distributed pub/sub backend.

tstypescript
// ❌ In-memory PubSub — breaks with multiple server instances
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Server A publishes an event — only Server A's subscribers see it
// Clients connected to Server B miss the event entirely
 
// ✅ Redis-backed PubSub — works across all server instances
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
 
const pubsub = new RedisPubSub({
  publisher: new Redis({ host: 'redis-host', port: 6379 }),
  subscriber: new Redis({ host: 'redis-host', port: 6379 }),
});
// Server A publishes → Redis broadcasts → all servers deliver
// to their connected subscribers
tstypescript
// Connection management for production
interface SubscriptionConfig {
  maxConnectionsPerUser: number;
  connectionTimeoutMs: number;
  heartbeatIntervalMs: number;
  maxSubscriptionsPerConnection: number;
}
 
const productionConfig: SubscriptionConfig = {
  maxConnectionsPerUser: 5,       // Prevent connection leaks
  connectionTimeoutMs: 30_000,    // Close idle connections
  heartbeatIntervalMs: 10_000,    // Detect dead connections
  maxSubscriptionsPerConnection: 20,  // Limit resource usage
};
 
// WebSocket server context with authentication
const wsServerOptions = {
  schema,
  context: async (ctx: { connectionParams: Record<string, unknown> }) => {
    const token = ctx.connectionParams?.authorization as string;
    if (!token) {
      throw new Error('Missing authentication token');
    }
    const user = await verifyToken(token);
    return { user };
  },
  onConnect: async (ctx: { connectionParams: Record<string, unknown> }) => {
    console.log('Client connected');
    // Validate authentication before allowing subscription
  },
  onDisconnect: () => {
    console.log('Client disconnected');
    // Clean up per-connection resources
  },
};

Redis-backed pub/sub ensures that an event published by any server instance reaches all subscribers regardless of which instance they are connected to. This is the standard pattern for horizontally scaled subscription servers.

Key Takeaways

  1. Subscriptions add push semantics to GraphQL — clients declare interest, servers push data through persistent WebSocket connections
  2. Use withFilter to ensure subscribers only receive events relevant to their query arguments
  3. Payload keys must match subscription field names — mismatches cause silent null delivery
  4. Replace in-memory PubSub with Redis when running multiple server instances behind a load balancer
  5. Enforce connection limits and authentication on WebSocket connections to prevent resource exhaustion
  6. Subscriptions complement queries, they do not replace them — initial load uses a query, then subscriptions stream incremental updates
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX