Skip to content
Codeloom
GraphQL

GraphQL Subscriptions for Real-Time Data

Build real-time features with GraphQL subscriptions using WebSockets, including setup with Apollo Server and client-side integration.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How GraphQL subscriptions work under the hood with WebSockets
  • How to set up subscriptions with Apollo Server and graphql-ws
  • How to consume subscriptions on the client with automatic reconnection

Prerequisites

  • Basic GraphQL knowledge
  • Familiarity with Node.js and Express

What Are GraphQL Subscriptions?

Queries and mutations follow a request-response pattern: the client asks, the server answers. Subscriptions break this pattern. A subscription establishes a persistent connection between client and server, allowing the server to push data to the client whenever a specific event occurs.

Common use cases include chat messages, live notifications, real-time dashboards, collaborative editing, and order status updates.

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

type Message {
  id: ID!
  text: String!
  sender: User!
  createdAt: String!
}

The Transport Layer: WebSockets

GraphQL subscriptions typically use WebSockets for the persistent connection. The modern standard is the graphql-ws protocol, which replaced the older subscriptions-transport-ws library. Always use graphql-ws for new projects as the older library is unmaintained.

Server Setup with Apollo Server

Install the required packages:

npm install @apollo/server graphql-ws ws graphql

Set up an Express server with both HTTP and WebSocket support:

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

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

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

// Create WebSocket server
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

// Set up graphql-ws
const serverCleanup = useServer({ schema }, 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 running on ws://localhost:4000/graphql');
});

The PubSub System

Subscriptions need a publish-subscribe mechanism. The server publishes events, and active subscriptions receive them. For development, use an in-memory PubSub. For production, use Redis or another external broker.

import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const MESSAGE_SENT = 'MESSAGE_SENT';

Writing Subscription Resolvers

Subscription resolvers differ from query and mutation resolvers. They return an AsyncIterator using the subscribe function:

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

  Mutation: {
    sendMessage: async (_, { channelId, text }, context) => {
      const message = await db.messages.create({
        channelId,
        text,
        senderId: context.userId,
      });

      // Publish the event
      await pubsub.publish(MESSAGE_SENT, {
        messageSent: message,
        channelId,
      });

      return message;
    },
  },

  Subscription: {
    messageSent: {
      subscribe: (_, { channelId }) => {
        return pubsub.asyncIterator([MESSAGE_SENT]);
      },
      // Optional: filter events to only relevant ones
      resolve: (payload) => payload.messageSent,
    },
  },
};

Filtering Subscriptions

Most subscriptions need filtering. A user subscribing to messages in channel “general” should not receive messages from channel “random”. Use withFilter for this:

import { withFilter } from 'graphql-subscriptions';

const resolvers = {
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([MESSAGE_SENT]),
        (payload, variables) => {
          // Only send to subscribers of this specific channel
          return payload.channelId === variables.channelId;
        }
      ),
      resolve: (payload) => payload.messageSent,
    },
  },
};

The filter function receives the published payload and the subscription variables. Return true to send the event to this subscriber, false to skip it.

Production PubSub with Redis

The in-memory PubSub only works for single-server deployments. With multiple server instances, you need an external broker. Redis is the most common choice:

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

const pubsub = new RedisPubSub({
  publisher: new Redis({ host: 'localhost', port: 6379 }),
  subscriber: new Redis({ host: 'localhost', port: 6379 }),
});

This is a drop-in replacement. The API is identical to the in-memory PubSub, but events are broadcast across all server instances through Redis.

Client-Side Integration

On the client, configure Apollo Client with a split link that routes subscriptions through WebSockets and everything else through HTTP:

npm install @apollo/client graphql-ws
import { ApolloClient, InMemoryCache, HttpLink, split } 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: {
      authToken: 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(),
});

Using Subscriptions in React

Use the useSubscription hook to listen for events:

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

const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageSent($channelId: ID!) {
    messageSent(channelId: $channelId) {
      id
      text
      sender {
        name
      }
      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>
      <p>New message: {data.messageSent.text}</p>
    </div>
  );
}

To combine initial data from a query with live updates from a subscription, use subscribeToMore:

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

function ChatRoom({ channelId }) {
  const { data, 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]);

  return (
    <ul>
      {data?.messages.map(msg => (
        <li key={msg.id}>{msg.text}</li>
      ))}
    </ul>
  );
}

Authentication in Subscriptions

WebSocket connections authenticate differently from HTTP requests. Pass authentication data through connectionParams:

// Server side: validate connection
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authToken;
      if (!token) throw new Error('Missing auth token');
      const user = await verifyToken(token);
      return { userId: user.id };
    },
    onConnect: async (ctx) => {
      const token = ctx.connectionParams?.authToken;
      if (!token) return false; // reject connection
    },
  },
  wsServer
);

When to Use Subscriptions vs. Polling

Subscriptions are ideal when data changes are infrequent but must appear instantly, such as chat messages or notifications. For data that changes frequently and slight delays are acceptable, polling with useQuery’s pollInterval option is simpler and more scalable. Subscriptions maintain persistent connections which consume server resources, so use them deliberately.

Summary

GraphQL subscriptions enable real-time features by maintaining a persistent WebSocket connection between client and server. Use graphql-ws as the transport protocol, implement PubSub for event distribution (Redis for production), filter events to relevant subscribers, and split your Apollo Client links to route subscriptions through WebSockets. Start with simple use cases like notifications before building more complex real-time features.