Skip to content

GraphQL Subscriptions with Apollo Server and Client

A practical guide to GraphQL subscriptions for real-time features: WebSocket setup in Apollo Server, subscription resolvers, client integration and scaling.

5 min read
Real-time data flowing through GraphQL subscription pipeline

When Queries and Mutations Are Not Enough

GraphQL queries fetch data. Mutations change data. But neither tells the client when data has changed. Without subscriptions, the client resorts to polling—repeatedly asking "has anything changed?" at fixed intervals. Polling works but wastes bandwidth, increases server load, and introduces latency equal to the polling interval.

Subscriptions solve this by pushing updates to the client the moment they happen. A chat message appears instantly. A stock price updates in real time. A deployment status changes without the user refreshing the page.

This guide builds a complete subscription system with Apollo Server and Apollo Client, covering the WebSocket infrastructure, resolver patterns, authentication on the subscription connection, and the production concerns that tutorials skip.

Server Setup: Apollo with WebSocket Transport

Apollo Server 4 does not include built-in subscription support. You pair it with the graphql-ws library for the WebSocket transport and Express (or Fastify) for the HTTP transport.

tstypescript
// src/server.ts
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { createServer } from "http";
import express from "express";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";
import { Context, createContext, createWsContext } from "./context";
 
const app = express();
const httpServer = createServer(app);
 
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  server: httpServer,
  path: "/graphql",
});
 
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx): Promise<Context> => {
      return createWsContext(ctx);
    },
    onConnect: async (ctx) => {
      const token = ctx.connectionParams?.authToken;
      if (!token) {
        throw new Error("Missing authentication token");
      }
      // Validate token — reject connection if invalid
      const user = await validateToken(token as string);
      if (!user) return false; // Reject connection
      return true;
    },
    onDisconnect: async (ctx) => {
      console.log("Client disconnected from subscriptions");
    },
  },
  wsServer
);
 
const server = new ApolloServer<Context>({
  schema,
  plugins: [
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});
 
await server.start();
 
app.use(
  "/graphql",
  express.json(),
  expressMiddleware(server, {
    context: createContext,
  })
);
 
httpServer.listen(4000, () => {
  console.log("Server running on http://localhost:4000/graphql");
  console.log("Subscriptions on ws://localhost:4000/graphql");
});

The dual transport setup is essential. HTTP handles queries and mutations. WebSocket handles subscriptions. Both share the same schema and endpoint path but use different protocols.

Schema Design for Subscriptions

graphqlgraphql
# src/schema.graphql
type Message {
  id: ID!
  content: String!
  author: User!
  channel: Channel!
  createdAt: DateTime!
}
 
type Channel {
  id: ID!
  name: String!
  messages(last: Int): [Message!]!
  memberCount: Int!
}
 
type Mutation {
  sendMessage(channelId: ID!, content: String!): Message!
  createChannel(name: String!): Channel!
}
 
type Subscription {
  messageAdded(channelId: ID!): Message!
  channelUpdated: Channel!
  userPresenceChanged(channelId: ID!): PresenceEvent!
}
 
type PresenceEvent {
  userId: ID!
  username: String!
  status: PresenceStatus!
  channelId: ID!
}
 
enum PresenceStatus {
  ONLINE
  OFFLINE
  TYPING
}

Subscription fields should be granular. Instead of a single onAnythingChanged subscription, define specific subscriptions for each event type a client might care about. This lets clients subscribe only to the events they need.

Resolvers: PubSub and Subscription Logic

The PubSub system connects mutations (which produce events) to subscriptions (which consume them). For development, an in-memory PubSub works. For production, use Redis-backed PubSub.

tstypescript
// src/pubsub.ts
import { PubSub } from "graphql-subscriptions";
import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
 
// Development: in-memory
const devPubSub = new PubSub();
 
// Production: Redis-backed for multi-instance support
const prodPubSub = new RedisPubSub({
  publisher: new Redis(process.env.REDIS_URL!),
  subscriber: new Redis(process.env.REDIS_URL!),
});
 
export const pubsub =
  process.env.NODE_ENV === "production" ? prodPubSub : devPubSub;
 
// Event name constants
export const EVENTS = {
  MESSAGE_ADDED: "MESSAGE_ADDED",
  CHANNEL_UPDATED: "CHANNEL_UPDATED",
  PRESENCE_CHANGED: "PRESENCE_CHANGED",
} as const;
tstypescript
// src/resolvers/mutation.ts
import { pubsub, EVENTS } from "../pubsub";
 
export const mutationResolvers = {
  Mutation: {
    sendMessage: async (
      _: unknown,
      args: { channelId: string; content: string },
      ctx: Context
    ) => {
      const message = await ctx.db.message.create({
        data: {
          content: args.content,
          channelId: args.channelId,
          authorId: ctx.user.id,
        },
        include: {
          author: true,
          channel: true,
        },
      });
 
      // Publish to subscribers
      await pubsub.publish(EVENTS.MESSAGE_ADDED, {
        messageAdded: message,
        channelId: args.channelId,
      });
 
      return message;
    },
  },
};
tstypescript
// src/resolvers/subscription.ts
import { withFilter } from "graphql-subscriptions";
import { pubsub, EVENTS } from "../pubsub";
 
export const subscriptionResolvers = {
  Subscription: {
    messageAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.MESSAGE_ADDED),
        (payload, variables) => {
          // Only send to subscribers watching this specific channel
          return payload.channelId === variables.channelId;
        }
      ),
    },
 
    channelUpdated: {
      subscribe: () =>
        pubsub.asyncIterableIterator(EVENTS.CHANNEL_UPDATED),
    },
 
    userPresenceChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.PRESENCE_CHANGED),
        (payload, variables) => {
          return payload.userPresenceChanged.channelId === variables.channelId;
        }
      ),
    },
  },
};

The withFilter function is critical for performance. Without it, every subscriber receives every event, and the client discards irrelevant ones. With filtering, the server only sends events to subscribers who care about that specific channel or entity.

Client Integration with Apollo Client

tstypescript
// src/lib/apollo-client.ts
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: "/graphql",
  credentials: "include",
});
 
const wsLink = new GraphQLWsLink(
  createClient({
    url: "ws://localhost:4000/graphql",
    connectionParams: () => ({
      authToken: getAuthToken(),
    }),
    retryAttempts: 5,
    shouldRetry: () => true,
    on: {
      connected: () => console.log("WS connected"),
      closed: () => console.log("WS closed"),
      error: (err) => console.error("WS error:", err),
    },
  })
);
 
// Route subscription operations to WebSocket, everything else to HTTP
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);
 
export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

The split function routes operations to the correct transport—subscriptions go over WebSocket, queries and mutations go over HTTP. This separation is important because WebSocket connections are long-lived and stateful, while HTTP requests are stateless and easier to load balance.

React Components with Live Updates

tsxtsx
// ❌ Bad: Polling for new messages
import { useQuery, gql } from "@apollo/client";
 
const MESSAGES_QUERY = gql`
  query Messages($channelId: ID!) {
    channel(id: $channelId) {
      messages(last: 50) {
        id
        content
        author { name }
        createdAt
      }
    }
  }
`;
 
function ChatBad({ channelId }: { channelId: string }) {
  const { data } = useQuery(MESSAGES_QUERY, {
    variables: { channelId },
    pollInterval: 1000, // Wasteful — 1 request/second
  });
  return <MessageList messages={data?.channel?.messages ?? []} />;
}
tsxtsx
// ✅ Good: Initial query + subscription for live updates
import { useQuery, useSubscription, gql } from "@apollo/client";
 
const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageAdded($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      content
      author {
        name
        avatar
      }
      createdAt
    }
  }
`;
 
function ChatChannel({ channelId }: { channelId: string }) {
  const { data, loading } = useQuery(MESSAGES_QUERY, {
    variables: { channelId },
  });
 
  useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
    onData: ({ client, data: subData }) => {
      const newMessage = subData.data?.messageAdded;
      if (!newMessage) return;
 
      // Update the Apollo cache with the new message
      client.cache.modify({
        id: client.cache.identify({
          __typename: "Channel",
          id: channelId,
        }),
        fields: {
          messages(existing = []) {
            const newRef = client.cache.writeFragment({
              data: newMessage,
              fragment: gql`
                fragment NewMessage on Message {
                  id
                  content
                  author { name avatar }
                  createdAt
                }
              `,
            });
            return [...existing, newRef];
          },
        },
      });
    },
  });
 
  if (loading) return <ChatSkeleton />;
 
  return <MessageList messages={data?.channel?.messages ?? []} />;
}

The onData callback manually updates the Apollo cache when a subscription event arrives. This approach is more reliable than subscribeToMore for complex cache updates because you have full control over how the new data merges with existing data.

Connection Management and Heartbeats

WebSocket connections die silently. Mobile networks drop connections without sending close frames. Load balancers terminate idle connections. Without heartbeats, clients end up with dead connections that never receive updates.

tstypescript
// Server-side: graphql-ws handles heartbeats automatically via ping/pong
// Client-side: configure keepalive and reconnection
const wsClient = createClient({
  url: "ws://localhost:4000/graphql",
  connectionParams: () => ({
    authToken: getAuthToken(),
  }),
  keepAlive: 10000, // Send ping every 10 seconds
  retryAttempts: Infinity, // Always retry
  retryWait: async (retries: number) => {
    // Exponential backoff with jitter
    const baseDelay = Math.min(1000 * 2 ** retries, 30000);
    const jitter = Math.random() * 1000;
    await new Promise((resolve) =>
      setTimeout(resolve, baseDelay + jitter)
    );
  },
});

Exponential backoff with jitter prevents thundering herd problems when a server restarts and all clients try to reconnect simultaneously. The jitter spreads reconnection attempts across a time window instead of concentrating them at the same instant.

Key Takeaways

GraphQL subscriptions transform your API from request-response to event-driven. The architecture requires a dual transport setup—HTTP for queries and mutations, WebSocket for subscriptions—with shared schema and authentication.

Server-side filtering with withFilter is the performance key. Without it, every connected client receives every event, which does not scale. Redis-backed PubSub enables multi-instance deployments by sharing events across server processes.

On the client, combine initial queries with subscriptions for the best user experience: load the current state immediately, then apply live updates incrementally. Always configure keepalive and exponential backoff for the WebSocket connection—silent disconnections are the most common production issue with subscriptions.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX