Why subscriptions over raw WebSockets
Raw WebSockets give you a bidirectional pipe with no structure. You end up inventing your own message format, your own error handling, your own type system. Subscriptions give you all of that for free because they ride on the GraphQL schema you already have.
Setting up with Apollo Server
Apollo Server 4 does not bundle a WebSocket transport. You need graphql-ws and wire it up alongside your HTTP handler:
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { ApolloServer } from '@apollo/server';
import { makeExecutableSchema } from '@graphql-tools/schema';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer({ schema }, wsServer);
const server = new ApolloServer({ schema });
await server.start();
app.use('/graphql', expressMiddleware(server));
httpServer.listen(4000);
The HTTP and WebSocket transports share the same schema. Queries and mutations go over HTTP. Subscriptions go over WebSocket. One schema, two transports.
The pub/sub layer
Subscription resolvers do not fetch data. They listen to a pub/sub channel and yield events:
const resolvers = {
Subscription: {
messageCreated: {
subscribe: () => pubsub.asyncIterableIterator(['MESSAGE_CREATED']),
},
},
Mutation: {
sendMessage: async (_, { text, channelId }) => {
const message = await db.message.create({ data: { text, channelId } });
pubsub.publish('MESSAGE_CREATED', { messageCreated: message });
return message;
},
},
};
In-memory pub/sub works for a single server. For multiple instances behind a load balancer, you need Redis-backed pub/sub so every server receives every event.
Scaling with Redis pub/sub
Swap the in-memory PubSub for RedisPubSub from graphql-redis-subscriptions:
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const pubsub = new RedisPubSub({
publisher: new Redis({ host: 'redis' }),
subscriber: new Redis({ host: 'redis' }),
});
Now a mutation on server A publishes to Redis, and the subscription resolver on server B picks it up and pushes it to the connected client.
Filtering events
Not every subscriber wants every event. Use withFilter to narrow delivery:
import { withFilter } from 'graphql-subscriptions';
subscribe: withFilter(
() => pubsub.asyncIterableIterator(['MESSAGE_CREATED']),
(payload, variables) => payload.messageCreated.channelId === variables.channelId,
)
This keeps server-side filtering efficient. The client only receives messages for the channel it subscribed to.
The gotcha: connection lifecycle
WebSocket connections are long-lived. If a client disconnects and reconnects, it misses every event published in between. For chat this is fine — load the backlog on reconnect. For financial data or notifications, you need a hybrid approach: subscription for live updates, query for the gap fill.
When not to use subscriptions
If your real-time requirement is "refresh every 30 seconds," polling is simpler and cheaper. Subscriptions shine when latency matters — chat, live dashboards, collaborative editing. For everything else, a simple refetchInterval is the right call.
