mdashikjs/blog
All posts
Building Real-Time Features with GraphQL Subscriptions
Backend

Building Real-Time Features with GraphQL Subscriptions

Backend5 min

Building Real-Time Features with GraphQL Subscriptions

REST polling wastes bandwidth. WebSockets are low-level. GraphQL subscriptions sit in the sweet spot: real-time push with the same type-safe schema you already use for queries and mutations.

GraphQLWebSocketReal-TimeApollo
Share:

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.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.