Back to blog
graphqlsubscriptionsrealtimebackend

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:

  • Client sends a subscription operation over a WebSocket connection
  • Server registers the client as a listener for a specific event
  • When that event fires, the server pushes the data back to the client
  • The client receives it like any other GraphQL response
  • 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/schema

    Define 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:

  • Swap to Redis PubSub — install graphql-redis-subscriptions and replace the in-memory instance before you go anywhere near production
  • Add authentication — wire up JWT validation in your onConnect handler
  • Explore subscriptions with Prisma — if you're using Prisma, you can trigger pubsub.publish() inside your Prisma middleware to react to any database change automatically
  • Test it — tools like [GraphQL Playground](https://github.com/graphql/graphql-playground) and Apollo Sandbox both support subscription testing out of the box
  • Real-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.