Building Real-Time Applications with GraphQL Subscriptions
If you're already using GraphQL for queries and mutations, adding real-time functionality feels like a natural next step. But a lot of developers hit a wall here — they know WebSockets exist, they…
Building Real-Time Applications with GraphQL Subscriptions
If you're already using GraphQL for queries and mutations, adding real-time functionality feels like a natural next step. But a lot of developers hit a wall here — they know WebSockets exist, they know polling is a hack, and they've heard about subscriptions but haven't wired it all together. This article fixes that.
Why Subscriptions Instead of Polling
Polling works. It's simple. But it's also wasteful — your client is hammering the server every few seconds asking "anything new?" even when the answer is almost always "nope."
WebSockets give you a persistent connection where the server pushes updates to the client the moment something changes. GraphQL subscriptions sit on top of that model and give you the same declarative, typed, schema-driven experience you already love about GraphQL.
The result: less network overhead, lower latency, and a much cleaner developer experience compared to rolling your own WebSocket logic.
How GraphQL Subscriptions Actually Work
At the protocol level, subscriptions use WebSockets (or sometimes Server-Sent Events). The most common implementation uses the graphql-ws protocol. Here's the flow:
The key piece on the server side is a PubSub system — something that lets you publish events and subscribe to them. Apollo Server ships with a basic in-memory PubSub, but for production you'd swap that out for Redis or a message broker.
Setting Up the Server
Let's build a simple chat message subscription using Apollo Server 4 and graphql-ws.
First, install your dependencies:
npm install @apollo/server graphql graphql-ws ws @graphql-tools/schemaDefine your schema:
type Message {
id: ID!
content: String!
author: String!
createdAt: String!
}type Query {
messages: [Message!]!
}
type Mutation {
sendMessage(content: String!, author: String!): Message!
}
type Subscription {
messageSent: Message!
}
Now wire up the resolvers with a PubSub instance:
import { PubSub } from 'graphql-subscriptions';const pubsub = new PubSub();
const MESSAGE_SENT = 'MESSAGE_SENT';
const messages = [];
const resolvers = {
Query: {
messages: () => messages,
},
Mutation: {
sendMessage: (_, { content, author }) => {
const message = {
id: String(Date.now()),
content,
author,
createdAt: new Date().toISOString(),
};
messages.push(message);
pubsub.publish(MESSAGE_SENT, { messageSent: message });
return message;
},
},
Subscription: {
messageSent: {
subscribe: () => pubsub.asyncIterator([MESSAGE_SENT]),
},
},
};
Notice the pattern: your mutation does its work, then calls pubsub.publish(). The subscription resolver just needs a subscribe function that returns an async iterator. Apollo handles the rest.
Connecting the HTTP and WebSocket Servers
Apollo Server 4 separates HTTP and WebSocket handling. Here's how to set both up together:
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = http.createServer(app);
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
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 at http://localhost:4000/graphql');
});
Both HTTP queries/mutations and WebSocket subscriptions share the same /graphql endpoint. Clean.
Client-Side Setup with Apollo Client
On the frontend, you need to configure Apollo Client to use WebSockets for subscriptions and HTTP for everything else. The split function handles this routing:
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' })
);
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(),
});
Then in a React component, subscribing is just a hook:
import { useSubscription, gql } from '@apollo/client';const MESSAGE_SENT_SUBSCRIPTION = gql`
subscription OnMessageSent {
messageSent {
id
content
author
createdAt
}
}
`;
function ChatFeed() {
const { data, loading } = useSubscription(MESSAGE_SENT_SUBSCRIPTION);
if (loading) return <p>Waiting for messages...</p>;
return (
<div>
<strong>{data.messageSent.author}:</strong> {data.messageSent.content}
</div>
);
}
Every time a new message is published on the server, this component re-renders with the fresh data. No polling, no manual WebSocket management.
Things to Watch Out For
Don't use in-memory PubSub in production. The built-in PubSub from graphql-subscriptions doesn't scale across multiple server instances. If you have two Node processes, a publish on one won't reach subscribers on the other. Use graphql-redis-subscriptions or a similar distributed solution.
Handle authentication carefully. WebSocket connections don't send cookies the same way HTTP requests do. You'll typically pass a token during the connection init phase and validate it in the onConnect callback of useServer.
useServer(
{
schema,
onConnect: async (ctx) => {
const token = ctx.connectionParams?.authToken;
if (!isValidToken(token)) {
throw new Error('Unauthorized');
}
},
},
wsServer
);Filter subscriptions per user. Not every subscriber should receive every event. Use the withFilter helper from graphql-subscriptions to conditionally push events based on the subscription variables or context.
import { withFilter } from 'graphql-subscriptions';Subscription: {
messageSent: {
subscribe: withFilter(
() => pubsub.asyncIterator([MESSAGE_SENT]),
(payload, variables) => {
return payload.messageSent.roomId === variables.roomId;
}
),
},
},
Next Steps
You've got the foundation. Here's where to go from here:
graphql-redis-subscriptions and replace the in-memory instance before you go anywhere near productiononConnect handlerpubsub.publish() inside your Prisma middleware to react to any database change automaticallyReal-time features go from "that sounds complicated" to "actually pretty manageable" once you understand the PubSub pattern. The hardest part is usually the infrastructure (Redis, scaling, auth) — the GraphQL layer itself stays clean and predictable.