GraphQL Subscriptions: A Practical Real‑Time Data Tutorial

Build real-time apps with GraphQL Subscriptions: step-by-step server, client, auth, scaling, and testing guidance using Node, WebSockets, and graphql-ws.

ASOasis
8 min read
GraphQL Subscriptions: A Practical Real‑Time Data Tutorial

Image used for representation purposes only.

Why GraphQL Subscriptions Matter

Real-time experiences—live dashboards, chat, notifications, multiplayer collaboration—are now table stakes. GraphQL Subscriptions let clients open a persistent connection to your API and receive data the moment it changes, using the same schema and types as your queries and mutations. In this tutorial, you’ll build a working subscription server and client, learn the protocols behind it, and get production checklists for scaling and securing your system.

What You’ll Build

We’ll implement a minimal chat-style example where:

  • Clients subscribe to messageSent events per room.
  • A mutation sendMessage publishes new messages to subscribed clients in that room.
  • We’ll use Node.js, GraphQL Yoga, and a PubSub mechanism. Then we’ll add a browser/React client with Apollo Client and graphql-ws.

Prerequisites

  • Node.js 18+ (for native fetch/WHATWG streams)
  • Basic familiarity with GraphQL types, queries, and mutations

Subscriptions, Transport, and Protocols (Quick Primer)

  • GraphQL is transport-agnostic. For real-time operations, the community standard is the GraphQL over WebSocket Protocol, implemented by the graphql-ws library.
  • A WebSocket keeps a persistent, bidirectional connection. The client first sends a connection_init frame (often with auth info), then starts one or more subscriptions. The server pushes results whenever events occur.
  • Server-Sent Events (SSE) can also deliver real-time streams, but most GraphQL tooling supports WebSockets out of the box, especially for subscriptions.

Project Setup

Create a new folder and install dependencies:

npm init -y
npm i graphql graphql-yoga graphql-subscriptions

Folder structure:

.
├─ package.json
└─ server.js

Define the Schema and Resolvers

We’ll create:

  • A Message type
  • A messageSent subscription filtered by room
  • A sendMessage mutation that publishes to a room-specific topic
// server.js
import { createServer } from 'node:http';
import { createSchema, createYoga } from 'graphql-yoga';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();
const TOPIC = (room) => `MESSAGE_SENT_${room}`;

const typeDefs = /* GraphQL */ `
  type Message {
    id: ID!
    text: String!
    user: String!
    createdAt: String!
    room: ID!
  }

  type Query {
    health: String!
  }

  type Mutation {
    sendMessage(room: ID!, text: String!, user: String!): Message!
  }

  type Subscription {
    messageSent(room: ID!): Message!
  }
`;

const resolvers = {
  Query: {
    health: () => 'ok',
  },
  Mutation: {
    sendMessage: async (_, { room, text, user }) => {
      const message = {
        id: Date.now().toString(),
        text,
        user,
        room,
        createdAt: new Date().toISOString(),
      };
      await pubsub.publish(TOPIC(room), { messageSent: message });
      return message;
    },
  },
  Subscription: {
    messageSent: {
      // Each room gets its own topic; subscribers only receive that room's events
      subscribe: (_, { room }) => pubsub.asyncIterator(TOPIC(room)),
    },
  },
};

const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  graphqlEndpoint: '/graphql',
});

const server = createServer(yoga);
server.listen(4000, () => {
  console.log('GraphQL endpoint ready at http://localhost:4000/graphql');
});

Start the server:

node server.js

Try It in GraphiQL

GraphQL Yoga ships with GraphiQL at the same URL. Open http://localhost:4000/graphql in your browser.

  1. In the left pane, start a subscription:
subscription OnMessage($room: ID!) {
  messageSent(room: $room) {
    id
    text
    user
    room
    createdAt
  }
}

Variables:

{ "room": "lobby" }
  1. In a new tab (or the second pane), execute a mutation:
mutation Send($room: ID!, $text: String!, $user: String!) {
  sendMessage(room: $room, text: $text, user: $user) {
    id
  }
}

Variables:

{ "room": "lobby", "text": "Hello, GraphQL Subscriptions!", "user": "Ada" }

Watch the subscription tab instantly receive the new message.

A Minimal Node Client (Optional)

You can also subscribe from a Node script using graphql-ws:

npm i graphql-ws cross-fetch
// client.js
import { createClient } from 'graphql-ws';
import fetch from 'cross-fetch';

const ws = createClient({ url: 'ws://localhost:4000/graphql' });

// 1) Start a subscription
const dispose = ws.subscribe(
  {
    query: `subscription($room: ID!) { messageSent(room: $room) { id text user room createdAt } }`,
    variables: { room: 'lobby' },
  },
  {
    next: (data) => console.log('event:', data),
    error: (err) => console.error('ws error:', err),
    complete: () => console.log('completed'),
  }
);

// 2) Trigger a message via HTTP mutation
await fetch('http://localhost:4000/graphql', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    query: `mutation($room: ID!, $text: String!, $user: String!) { sendMessage(room:$room, text:$text, user:$user){ id } }`,
    variables: { room: 'lobby', text: 'Hi from Node', user: 'Turing' },
  }),
});

// Later, when you want to stop listening
// dispose();

Run it with node client.js and observe real-time events in your terminal.

React + Apollo Client Setup

In a React app, combine HTTP for queries/mutations and WebSocket for subscriptions using a split link.

npm i @apollo/client graphql graphql-ws
// apollo.js
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: async () => ({
      // e.g., send a token; see Auth section below
      // authorization: `Bearer ${localStorage.getItem('token')}`
    }),
    retryAttempts: 10,
  })
);

const splitLink = split(
  ({ query }) => {
    const def = getMainDefinition(query);
    return def.kind === 'OperationDefinition' && def.operation === 'subscription';
  },
  wsLink,
  httpLink
);

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

Then in a component:

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

const MESSAGE_SENT = gql`
  subscription OnMessage($room: ID!) {
    messageSent(room: $room) { id text user room createdAt }
  }
`;

const SEND_MESSAGE = gql`
  mutation Send($room: ID!, $text: String!, $user: String!) {
    sendMessage(room: $room, text: $text, user: $user) { id }
  }
`;

export function Chat({ room }) {
  const { data } = useSubscription(MESSAGE_SENT, { variables: { room } });
  const [send] = useMutation(SEND_MESSAGE);

  return (
    <div>
      <pre>{JSON.stringify(data?.messageSent, null, 2)}</pre>
      <button onClick={() => send({ variables: { room, text: 'Hello', user: 'Grace' } })}>
        Send
      </button>
    </div>
  );
}

Authentication and Context

Typical patterns:

  • Send a JWT or API key in connectionParams during the WebSocket connection_init.
  • Validate it on the server and inject the user into the GraphQL context so resolvers and subscription filters can enforce authorization.

If you manage the WebSocket server yourself with graphql-ws:

import { useServer } from 'graphql-ws/lib/use/ws';
import { WebSocketServer } from 'ws';

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

useServer({
  schema,
  onSubscribe: async (ctx, msg) => {
    const token = ctx.connectionParams?.authorization?.replace('Bearer ', '');
    const user = token ? await verifyJwt(token) : null;
    // Optionally reject unauthorized connections
    if (!user) throw new Error('Unauthorized');
    return { schema, context: { user } };
  },
}, wsServer);

With Yoga’s built-in WebSocket support, you can keep using your normal context factory. Libraries differ, but the idea is the same: read connection params, validate, and expose user on context.

Filtering and Fine-Grained Updates

  • Narrow topics (e.g., MESSAGE_SENT_roomId) reduce over-broadcasting.
  • You can also filter in the resolver:
Subscription: {
  messageSent: {
    subscribe: async function* (_, { room }, { user }) {
      // Example: ensure user is allowed in this room
      if (!canJoinRoom(user, room)) throw new Error('Forbidden');
      for await (const event of pubsub.asyncIterator(TOPIC(room))) {
        // Optionally transform or redact fields here
        yield event;
      }
    },
  },
}

Production Hardening and Scaling

  • Sticky sessions: Many load balancers require session affinity for WebSockets. Enable it or terminate WS at an edge that supports stickiness.
  • Horizontal scale: The in-memory PubSub only works on a single node. Use a shared message bus.
    • Drop-in: graphql-redis-subscriptions with Redis
    • Advanced: Kafka, NATS, or RabbitMQ via custom PubSub
  • Backpressure and fan-out: Avoid publishing massive payloads. Consider streaming deltas or IDs that clients refetch.
  • Heartbeats and retries: Ensure ping/pong and exponential backoff on the client (graphql-ws supports retries).
  • Limits: Rate-limit subscriptions per user; cap concurrent operations; validate query complexity to prevent abuse.
  • Observability: Log subscribe/start/complete; add metrics for active connections, events/sec, and broadcast latency.
  • Timeouts: Auto-disconnect idle sockets; handle half-open connections.

Redis PubSub Example

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

const pubsub = new RedisPubSub({
  connection: { host: '127.0.0.1', port: 6379 },
});

// The rest of your resolvers remain the same

Testing a Subscription End-to-End

A pragmatic approach is “subscribe, then mutate, then expect event.” With Vitest/Jest you can do:

import { createClient } from 'graphql-ws';
import fetch from 'cross-fetch';
import { expect, test } from 'vitest';

const ws = createClient({ url: 'ws://localhost:4000/graphql' });

test('messageSent delivers events', async () => {
  await new Promise((resolve, reject) => {
    let sawEvent = false;

    const dispose = ws.subscribe(
      { query: 'subscription($room:ID!){ messageSent(room:$room){ text } }', variables: { room: 'test' } },
      {
        next: (data) => {
          sawEvent = true;
          expect(data.data.messageSent.text).toBe('ping');
          dispose();
          resolve();
        },
        error: reject,
        complete: () => (!sawEvent ? reject(new Error('no event')) : resolve()),
      }
    );

    fetch('http://localhost:4000/graphql', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        query: 'mutation($r:ID!,$t:String!,$u:String!){ sendMessage(room:$r,text:$t,user:$u){ id } }',
        variables: { r: 'test', t: 'ping', u: 'tester' },
      }),
    });
  });
});

Troubleshooting Guide

  • Browser shows “connection not acknowledged”
    • Ensure the client sends connection_init (graphql-ws does this automatically) and the server supports the GraphQL over WebSocket Protocol.
  • Nothing arrives on subscription, but mutation works
    • Verify the topic string matches exactly; add logs around publish/subscribe.
  • Works locally, fails in prod
    • Check load balancer/WebSocket support and sticky sessions; verify proxies aren’t downgrading to HTTP/1.0 or closing idle connections too aggressively.
  • Auth header not visible in subscription context
    • For WebSockets, send tokens via connectionParams, not HTTP headers (unless your server maps them).
  • High memory usage
    • Ensure you dispose of subscriptions on client route changes and server disconnects; set sane keep-alive intervals and idle timeouts.

When to Consider Managed/Hosted Options

  • Hasura, AWS AppSync, or Supabase can provide real-time GraphQL with managed websockets, authorization, and scaling. Great for small teams or when infra isn’t your core business.

Recap and Next Steps

You implemented a real-time GraphQL API using subscriptions, wired a client with Apollo and graphql-ws, and learned patterns for authentication, filtering, and scale. Next, consider:

  • Moving PubSub to Redis (or Kafka for very high throughput)
  • Implementing fine-grained authorization per resource
  • Adding retry/backoff telemetry and dashboards
  • Migrating heavy payloads to event IDs + follow-up queries to reduce bandwidth

Real-time UX is a capability multiplier. With subscriptions in your toolkit, you can ship it with the same GraphQL ergonomics your team already loves.

Related Posts