Implementing GraphQL Subscriptions with Apollo Server and WebSocket in Node.js
Learn how to add real‑time GraphQL subscriptions to a Node.js app using Apollo Server, graphql‑ws, and a shared PubSub engine. The guide covers schema, server setup, authentication, scaling, and troubleshooting steps.
03 Jun 2026, 21:38 UTC

Desired Outcome
By the end of this guide you will have a Node.js application that exposes a GraphQL API with a subscription type. Clients can open a WebSocket connection, send a subscription query, and receive real‑time events that are published from the server.
Prerequisites
- Node.js 18+ (LTS recommended)
- npm or yarn
- Basic understanding of GraphQL schemas and resolvers
- Optional: Redis instance for a shared PubSub engine (recommended for multi‑instance deployments)
Step‑by‑Step Setup
- Initialize the project
mkdir graphql-subscriptions-demo cd graphql-subscriptions-demo npm init -y - Install dependencies
npm install apollo-server graphql graphql-ws graphql-subscriptions ioredisUse
ioredisto connect to Redis. If you prefer another broker, replace the import accordingly. - Create the schema
// schema.js const { gql } = require('apollo-server'); const typeDefs = gql` type Message { id: ID! content: String! createdAt: String! } type Query { messages: [Message!]! } type Mutation { postMessage(content: String!): Message! } type Subscription { messagePosted: Message! } `; module.exports = typeDefs; - Set up PubSub with Redis
// pubsub.js const { RedisPubSub } = require('graphql-subscriptions'); const Redis = require('ioredis'); const pubsub = new RedisPubSub({ publisher: new Redis(process.env.REDIS_URL), subscriber: new Redis(process.env.REDIS_URL), }); module.exports = pubsub;Set
REDIS_URLin a .env file or your environment. The in‑memory PubSub fromgraphql-subscriptionsis omitted because it does not survive restarts or horizontal scaling. - Implement resolvers
// resolvers.js const { v4: uuidv4 } = require('uuid'); const pubsub = require('./pubsub'); const MESSAGE_POSTED = 'MESSAGE_POSTED'; const resolvers = { Query: { messages: () => [], // placeholder – replace with DB logic }, Mutation: { postMessage: async (_, { content }) => { const message = { id: uuidv4(), content, createdAt: new Date().toISOString(), }; // In a real app, persist to DB here await pubsub.publish(MESSAGE_POSTED, { messagePosted: message }); return message; }, }, Subscription: { messagePosted: { // The resolver must return an AsyncIterator subscribe: () => pubsub.asyncIterator([MESSAGE_POSTED]), // Optional filtering – only deliver to users with a matching role // filter: (payload, variables, context) => true, }, }, }; module.exports = resolvers; - Configure Apollo Server with WebSocket support
// index.js const { ApolloServer } = require('apollo-server'); const { useServer } = require('graphql-ws/lib/use/ws'); const { WebSocketServer } = require('ws'); const http = require('http'); const typeDefs = require('./schema'); const resolvers = require('./resolvers'); const httpServer = http.createServer(); const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' }); // Attach graphql-ws to the WebSocket server const serverCleanup = useServer( { schema: { typeDefs, resolvers }, // Context is available for both HTTP and WebSocket connections context: async (ctx, msg, args) => { // Example: validate JWT from connectionParams const { authToken } = ctx.connectionParams || {}; if (!authToken) throw new Error('Missing auth token'); // Verify token and attach user to context if needed return { user: { id: '123', role: 'admin' } }; }, }, wsServer ); const server = new ApolloServer({ typeDefs, resolvers, plugins: [ { async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); }, }; }, }, ], }); async function start() { await server.start(); server.applyMiddleware({ app: httpServer }); httpServer.listen({ port: 4000 }, () => { console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); console.log(`🚀 WebSocket ready at ws://localhost:4000/graphql`); }); } start();Key points in the configuration:
graphql-wshandles the WebSocket upgrade and handshake.- Context validation runs for both HTTP and WebSocket connections, enabling JWT authentication.
- Shared PubSub (Redis) ensures all server instances see the same events.
Validation and Checks
- Test the subscription locally – run
node index.js, open Apollo Studio Sandbox or GraphiQL, and execute:subscription { messagePosted { id content createdAt } }In another terminal or client, run the mutation
postMessage(content: "Hello World")and confirm the subscription receives the payload in real time. - Verify PubSub distribution across instances – deploy two server instances behind a load balancer, publish an event, and ensure both clients receive it. If one client misses events, the PubSub backend is not shared.
- Check WebSocket cleanup – after a client disconnects, use
netstat -an | grep 4000or a monitoring tool to confirm the socket count drops. Persistent sockets indicate a leak. - Security test – attempt to open a WebSocket connection without
authTokenor with an invalid token; the server should reject the handshake with a 401 error.
Recovery and Rollback
- If the server fails to start, ensure
REDIS_URLis reachable and the Redis client can connect. TheuseServerhook will throw a connection error otherwise. - When scaling, if events stop propagating, double‑check that all instances point to the same Redis endpoint. A mismatch causes isolated PubSub clusters.
- For a quick rollback of the subscription feature, comment out the
Subscriptiontype and resolvers inschema.jsandresolvers.js, then restart the server. This avoids changing runtime state beyond the server restart.
Production Considerations
- Use a shared PubSub broker – Redis, MQTT, or a message queue such as RabbitMQ ensures all instances receive events. Avoid the default in‑memory PubSub in production.
- Rate limiting and authentication – Apply WebSocket rate limits and enforce JWT validation to mitigate DoS attacks.
- Sticky sessions – If you cannot share PubSub, configure the load balancer to keep a client connected to the same server instance (HTTP sticky sessions) to avoid dropped subscriptions.
- Monitoring – Expose metrics for open connections, event throughput, and latency. Prometheus with Node Exporter or Grafana can surface these.
- Graceful shutdown – The
drainServerplugin ensures that WebSocket connections are closed cleanly when the process receives a SIGTERM.
Limitations
- Subscriptions keep a TCP connection open; on high‑traffic systems, consider scaling WebSocket servers horizontally and using a shared PubSub.
- Debugging subscription errors can be opaque; enable
debug: trueingraphql-wsand review server logs for handshake failures. - Event ordering is guaranteed only within a single PubSub channel; cross‑channel ordering is not preserved.
With the above steps you now have a robust, authenticated GraphQL subscription pipeline that scales across multiple Node.js instances and can be monitored and secured for production workloads.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.