Skip to content
Codeloom
GraphQL

GraphQL Subscriptions for Real-Time Data

Build real-time features with GraphQL subscriptions using WebSockets, PubSub engines, and scalable event-driven architectures.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How GraphQL subscriptions work over WebSockets
  • How to implement PubSub for event-driven updates
  • How to set up subscriptions with Apollo Server
  • How to consume subscriptions on the client
  • How to scale subscriptions with Redis PubSub

Prerequisites

  • Basic GraphQL queries and mutations
  • Familiarity with Node.js and Express

What Are GraphQL Subscriptions?

Queries fetch data once. Mutations change data once. Subscriptions keep a persistent connection open so the server can push updates to the client whenever relevant data changes. Under the hood, this typically uses WebSockets — a full-duplex communication channel over a single TCP connection.

Subscriptions are ideal for chat applications, live dashboards, notifications, collaborative editing, and any feature where users expect to see changes without refreshing the page.

The Subscription Lifecycle

A subscription follows this flow:

  1. The client sends a subscription operation to the server
  2. The server establishes a long-lived WebSocket connection
  3. When a relevant event occurs (usually triggered by a mutation), the server publishes the event
  4. The server pushes the data to all clients subscribed to that event
  5. The connection stays open until the client unsubscribes or disconnects

Setting Up the Server

First, install the required packages:

npm install @apollo/server graphql-ws ws graphql-subscriptions graphql

Define a schema with a subscription type:

type Message {
  id: ID!
  content: String!
  author: String!
  createdAt: String!
}

type Query {
  messages(channelId: ID!): [Message!]!
}

type Mutation {
  sendMessage(channelId: ID!, content: String!, author: String!): Message!
}

type Subscription {
  messageSent(channelId: ID!): Message!
}

Implementing PubSub

The PubSub system is the backbone of subscriptions. It manages event channels and notifies subscribers when new events are published. Apollo provides a simple in-memory PubSub for development:

import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const MESSAGE_SENT = 'MESSAGE_SENT';

const resolvers = {
  Query: {
    messages: async (_, { channelId }, { db }) => {
      return db.messages.findByChannel(channelId);
    },
  },

  Mutation: {
    sendMessage: async (_, { channelId, content, author }, { db }) => {
      const message = await db.messages.create({
        channelId,
        content,
        author,
        createdAt: new Date().toISOString(),
      });

      // Publish the event -- all subscribers will receive it
      await pubsub.publish(MESSAGE_SENT, {
        messageSent: message,
        channelId,
      });

      return message;
    },
  },

  Subscription: {
    messageSent: {
      subscribe: (_, { channelId }) => {
        return pubsub.asyncIterableIterator(MESSAGE_SENT);
      },
      resolve: (payload) => {
        return payload.messageSent;
      },
    },
  },
};

Filtering Events

Not every subscriber should receive every event. The withFilter helper lets you filter events based on subscription arguments:

import { withFilter } from 'graphql-subscriptions';

const resolvers = {
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(MESSAGE_SENT),
        (payload, variables) => {
          // Only send the event if the channelId matches
          return payload.channelId === variables.channelId;
        }
      ),
    },
  },
};

Now a client subscribing to messageSent(channelId: "general") will only receive messages posted in the “general” channel, not every message across all channels.

Setting Up the WebSocket Server

Apollo Server does not handle WebSockets directly. You need graphql-ws alongside your HTTP server:

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';

const app = express();
const httpServer = createServer(app);

const schema = makeExecutableSchema({ typeDefs, resolvers });

// Set up WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // Access connection params for auth
      const token = ctx.connectionParams?.authorization;
      const user = token ? await verifyToken(token) : null;
      return { user, db };
    },
    onConnect: async (ctx) => {
      console.log('Client connected');
      // Return false to reject the connection
    },
    onDisconnect: (ctx) => {
      console.log('Client disconnected');
    },
  },
  wsServer
);

const server = new ApolloServer({
  schema,
  plugins: [
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

await server.start();

app.use('/graphql', express.json(), expressMiddleware(server));

httpServer.listen(4000, () => {
  console.log('Server running on http://localhost:4000/graphql');
  console.log('WebSocket server running on ws://localhost:4000/graphql');
});

Client-Side Subscriptions

On the client, configure Apollo Client with a split link that routes subscriptions to WebSocket and queries/mutations to HTTP:

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';

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
});

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    connectionParams: {
      authorization: localStorage.getItem('token'),
    },
  })
);

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(),
});

Now use the useSubscription hook in React:

import { useSubscription, gql } from '@apollo/client';

const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageSent($channelId: ID!) {
    messageSent(channelId: $channelId) {
      id
      content
      author
      createdAt
    }
  }
`;

function ChatMessages({ channelId }) {
  const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
  });

  if (loading) return <p>Listening for messages...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div className="new-message">
      <strong>{data.messageSent.author}:</strong> {data.messageSent.content}
    </div>
  );
}

For a complete chat experience, combine useQuery to load existing messages with subscribeToMore to append new ones:

import { useQuery, gql } from '@apollo/client';

const GET_MESSAGES = gql`
  query GetMessages($channelId: ID!) {
    messages(channelId: $channelId) {
      id
      content
      author
      createdAt
    }
  }
`;

function ChatRoom({ channelId }) {
  const { data, loading, subscribeToMore } = useQuery(GET_MESSAGES, {
    variables: { channelId },
  });

  useEffect(() => {
    const unsubscribe = subscribeToMore({
      document: MESSAGE_SUBSCRIPTION,
      variables: { channelId },
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) return prev;
        const newMessage = subscriptionData.data.messageSent;
        return {
          messages: [...prev.messages, newMessage],
        };
      },
    });

    return () => unsubscribe();
  }, [channelId, subscribeToMore]);

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      {data.messages.map((msg) => (
        <div key={msg.id}>
          <strong>{msg.author}:</strong> {msg.content}
        </div>
      ))}
    </div>
  );
}

Scaling with Redis PubSub

The in-memory PubSub only works for a single server instance. If you run multiple server replicas behind a load balancer, events published on one instance will not reach subscribers connected to another. Redis PubSub solves this by acting as a shared message broker:

npm install graphql-redis-subscriptions ioredis
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const options = {
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  retryStrategy: (times) => Math.min(times * 50, 2000),
};

const pubsub = new RedisPubSub({
  publisher: new Redis(options),
  subscriber: new Redis(options),
});

// Use exactly the same way as in-memory PubSub
await pubsub.publish(MESSAGE_SENT, { messageSent: message, channelId });

The resolver code stays identical. Only the PubSub instantiation changes. This means you can start with in-memory PubSub during development and swap in Redis for production with a single configuration change.

Connection Management and Best Practices

WebSocket connections are long-lived and consume server resources. Follow these practices to keep things stable:

Heartbeats and timeouts. The graphql-ws library handles ping/pong automatically, but configure reasonable timeouts:

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    keepAlive: 10000, // Send keep-alive every 10 seconds
    retryAttempts: 5,
    retryWait: (retries) => new Promise((resolve) =>
      setTimeout(resolve, Math.min(1000 * 2 ** retries, 30000))
    ),
  })
);

Authentication on connect. Validate tokens during the WebSocket handshake, not on every message. Reject unauthorized connections in onConnect to avoid wasting resources.

Limit subscription depth. Just like queries, subscriptions can request deeply nested fields. Apply the same query complexity analysis to subscription operations.

Clean up on disconnect. Track active subscriptions and release resources when clients disconnect. The onDisconnect callback in graphql-ws is the right place for this.

When Not to Use Subscriptions

Subscriptions add complexity. Consider alternatives for simpler cases:

  • Polling works well when data changes infrequently and near-real-time is acceptable. A query every 5 seconds is simpler than maintaining a WebSocket.
  • Server-Sent Events (SSE) provide one-way server-to-client streaming without the overhead of a full WebSocket connection.
  • HTTP streaming can work for scenarios where you need a one-time stream of events rather than an ongoing subscription.

Use subscriptions when you need low-latency bidirectional communication, when multiple clients need to stay in sync, and when the data changes frequently enough that polling would be wasteful.