DevToolBoxZA DARMO
Blog

GraphQL Subscriptions Guide: Real-time Data with WebSockets and Apollo

13 min readby DevToolBox

TL;DR

GraphQL subscriptions push real-time data over persistent WebSocket connections. Use graphql-ws (not the deprecated subscriptions-transport-ws). Publish events server-side via PubSub and consume them client-side with the useSubscription hook. Use Redis PubSub for horizontal scaling in production. Pass JWT tokens via connectionParams for WebSocket authentication. Use SSE as an alternative when WebSockets are blocked by proxies.

Introduction to GraphQL Subscriptions

Modern applications demand real-time data. Users expect instant messages, live dashboard updates, collaborative editing, and push notifications. Traditional polling is wasteful and hard to scale. GraphQL subscriptions provide a standardized way to push server-side events to clients over persistent connections, typically implemented with WebSocket.

Subscriptions follow a publish/subscribe pattern: the server publishes events to a topic, and subscribed clients receive those events automatically. Unlike queries and mutations, which follow a single request-response cycle, subscriptions receive multiple responses over the lifetime of the connection.

This guide covers everything from fundamentals to production deployment: Apollo Server and Client configuration, authentication, Redis scaling, SSE alternatives, and error-handling strategies.

How GraphQL Subscriptions Work

Subscriptions establish a persistent, bidirectional connection via WebSocket. Here is the full lifecycle:

Client                              Server
  |                                    |
  |------ WebSocket Handshake -------->|
  |<-------- Connection Ack -----------|
  |                                    |
  |------ connection_init ------------>|  (graphql-ws protocol)
  |<-------- connection_ack -----------|
  |                                    |
  |------ subscribe (operation) ------>|
  |<-------- next (data push) ---------|
  |<-------- next (data push) ---------|
  |<-------- next (data push) ---------|
  |                                    |
  |------ complete (unsubscribe) ----->|
  |<-------- complete -----------------|
  |                                    |
  |------ close connection ----------->|

The Three GraphQL Operation Types

# Query: one-time data fetch (HTTP POST or GET)
query GetUser {
  user(id: "123") {
    name
    email
  }
}

# Mutation: one-time data change (HTTP POST)
mutation CreateMessage {
  createMessage(input: { content: "Hello", channelId: "general" }) {
    id
    content
    createdAt
  }
}

# Subscription: persistent event stream (WebSocket)
subscription OnNewMessage {
  messageSent(channelId: "general") {
    id
    content
    sender {
      name
      avatar
    }
    createdAt
  }
}

WebSocket Transport: graphql-ws vs subscriptions-transport-ws

Choosing the correct WebSocket transport library matters. There are two protocols — do not confuse them:

Featuregraphql-wssubscriptions-transport-ws
Protocol Namegraphql-transport-wsgraphql-ws (legacy)
MaintenanceActively maintainedDeprecated
Apollo Server 4 supportNative supportNot supported
SecurityNo known issuesKnown vulnerabilities
Connection initconnection_init / connection_ackconnection_init / connection_ack
Subscribe messagesubscribestart
Data messagenextdata
Recommended forAll new projectsLegacy migration only

Bottom line: always use graphql-ws (implementing the graphql-transport-ws protocol) for all new projects. If you are currently on subscriptions-transport-ws, migrate immediately.

Apollo Server Subscriptions Setup with graphql-ws

Apollo Server 4 supports WebSocket subscriptions natively through graphql-ws. Here is the complete server setup:

Install Dependencies

# Install required packages
npm install @apollo/server graphql graphql-ws ws

# For Express integration
npm install @apollo/server express4 express cors

# TypeScript types
npm install -D @types/ws @types/express @types/cors

GraphQL Schema Definition

// src/schema.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  type Message {
    id: ID!
    content: String!
    sender: User!
    channelId: String!
    createdAt: String!
  }

  type User {
    id: ID!
    name: String!
    avatar: String
  }

  type Query {
    messages(channelId: String!, limit: Int = 20): [Message!]!
    channels: [String!]!
  }

  type Mutation {
    sendMessage(channelId: String!, content: String!): Message!
  }

  type Subscription {
    messageSent(channelId: String!): Message!
    userJoined(channelId: String!): User!
    userLeft(channelId: String!): User!
  }
`;

Complete Apollo Server + Express + WebSocket Setup

// src/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
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';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  // Build the executable schema
  const schema = makeExecutableSchema({ typeDefs, resolvers });

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

  // Set up graphql-ws server
  const serverCleanup = useServer(
    {
      schema,
      // Context for subscriptions (called per subscription operation)
      context: async (ctx, msg, args) => {
        // Extract auth token from connection params
        const token = ctx.connectionParams?.authorization as string;
        const user = token ? await verifyToken(token) : null;
        return { user, pubsub };
      },
      onConnect: async (ctx) => {
        // Called when client connects via WebSocket
        console.log('Client connected', ctx.connectionParams);
        return true; // Accept all connections (add auth here for rejection)
      },
      onDisconnect: (ctx, code, reason) => {
        console.log('Client disconnected', code, reason);
      },
    },
    wsServer
  );

  // Create Apollo Server (HTTP for queries/mutations)
  const server = new ApolloServer({
    schema,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      // Gracefully shutdown WebSocket server
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use(
    '/graphql',
    cors<cors.CorsRequest>({
      origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
      credentials: true,
    }),
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => {
        const token = req.headers.authorization?.replace('Bearer ', '');
        const user = token ? await verifyToken(token) : null;
        return { user };
      },
    })
  );

  const PORT = process.env.PORT || 4000;
  httpServer.listen(PORT, () => {
    console.log('HTTP server running at http://localhost:' + PORT + '/graphql');
    console.log('WebSocket server running at ws://localhost:' + PORT + '/graphql');
  });
}

startServer().catch(console.error);

Subscription Resolvers: PubSub and AsyncIterator

Subscription resolvers differ from Query/Mutation resolvers: they must return an AsyncIterator. The PubSub class manages publishing and subscribing to events.

// src/resolvers.ts
import { PubSub, withFilter } from 'graphql-subscriptions';

// In-memory PubSub (single server only — use Redis for production)
export const pubsub = new PubSub();

// Event topic names — use constants to avoid typos
const EVENTS = {
  MESSAGE_SENT: 'MESSAGE_SENT',
  USER_JOINED: 'USER_JOINED',
  USER_LEFT: 'USER_LEFT',
} as const;

export const resolvers = {
  Query: {
    messages: async (_: unknown, { channelId, limit }: { channelId: string; limit: number }) => {
      return await db.messages.findMany({
        where: { channelId },
        orderBy: { createdAt: 'desc' },
        take: limit,
        include: { sender: true },
      });
    },
  },

  Mutation: {
    sendMessage: async (
      _: unknown,
      { channelId, content }: { channelId: string; content: string },
      { user }: { user: User }
    ) => {
      if (!user) throw new Error('Unauthorized');

      const message = await db.messages.create({
        data: { channelId, content, senderId: user.id },
        include: { sender: true },
      });

      // Publish the event — all subscribers to MESSAGE_SENT will receive it
      await pubsub.publish(EVENTS.MESSAGE_SENT, { messageSent: message });

      return message;
    },
  },

  Subscription: {
    messageSent: {
      // subscribe returns an AsyncIterator
      subscribe: withFilter(
        // The base iterator — subscribes to all MESSAGE_SENT events
        () => pubsub.asyncIterator([EVENTS.MESSAGE_SENT]),
        // Filter function — return true to deliver, false to skip
        (payload: { messageSent: Message }, variables: { channelId: string }) => {
          return payload.messageSent.channelId === variables.channelId;
        }
      ),
    },

    userJoined: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([EVENTS.USER_JOINED]),
        (payload, variables) => payload.userJoined.channelId === variables.channelId
      ),
    },

    userLeft: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([EVENTS.USER_LEFT]),
        (payload, variables) => payload.userLeft.channelId === variables.channelId
      ),
    },
  },
};

Apollo Client Subscription Setup: useSubscription Hook

The client needs GraphQLWsLink for WebSocket connections combined with HttpLink as a split link: HTTP handles queries/mutations and WebSocket handles subscriptions.

Apollo Client Complete Configuration

// src/lib/apollo-client.ts
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  split,
  from,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { createClient } from 'graphql-ws';

// HTTP link for queries and mutations
const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_HTTP_URL || 'http://localhost:4000/graphql',
});

// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
  createClient({
    url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL || 'ws://localhost:4000/graphql',
    connectionParams: () => {
      // Pass authentication token via connectionParams
      const token = localStorage.getItem('auth_token');
      return {
        authorization: token ? 'Bearer ' + token : '',
      };
    },
    retryAttempts: 5,
    shouldRetry: () => true,
    on: {
      connected: () => console.log('WebSocket connected'),
      closed: () => console.log('WebSocket closed'),
      error: (err) => console.error('WebSocket error:', err),
    },
  })
);

// Authentication middleware for HTTP requests
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('auth_token');
  return {
    headers: {
      ...headers,
      authorization: token ? 'Bearer ' + token : '',
    },
  };
});

// Error handling link
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, path }) => {
      console.error('[GraphQL error]', message, 'at path:', path);
    });
  }
  if (networkError) {
    console.error('[Network error]', networkError);
  }
});

// Split: use WebSocket for subscriptions, HTTP for everything else
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,     // Subscriptions go here
  from([errorLink, authLink, httpLink])  // Queries/mutations go here
);

export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          messages: {
            keyArgs: ['channelId'],
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
    },
  }),
});

Using useSubscription in React Components

// src/components/ChatChannel.tsx
'use client';

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

const GET_MESSAGES = gql`
  query GetMessages($channelId: String!, $limit: Int) {
    messages(channelId: $channelId, limit: $limit) {
      id
      content
      createdAt
      sender {
        id
        name
        avatar
      }
    }
  }
`;

const MESSAGE_SENT_SUBSCRIPTION = gql`
  subscription OnMessageSent($channelId: String!) {
    messageSent(channelId: $channelId) {
      id
      content
      createdAt
      sender {
        id
        name
        avatar
      }
    }
  }
`;

const SEND_MESSAGE_MUTATION = gql`
  mutation SendMessage($channelId: String!, $content: String!) {
    sendMessage(channelId: $channelId, content: $content) {
      id
      content
      createdAt
    }
  }
`;

interface Message {
  id: string;
  content: string;
  createdAt: string;
  sender: { id: string; name: string; avatar?: string };
}

export default function ChatChannel({ channelId }: { channelId: string }) {
  const [content, setContent] = React.useState('');

  // Initial data fetch
  const { data, loading, error } = useQuery(GET_MESSAGES, {
    variables: { channelId, limit: 50 },
  });

  // Real-time subscription
  useSubscription(MESSAGE_SENT_SUBSCRIPTION, {
    variables: { channelId },
    onData: ({ client, data: subData }) => {
      const newMessage = subData.data?.messageSent;
      if (!newMessage) return;

      // Update Apollo cache with the new message
      client.cache.updateQuery(
        { query: GET_MESSAGES, variables: { channelId, limit: 50 } },
        (existing) => {
          if (!existing) return existing;
          // Avoid duplicates
          const exists = existing.messages.some((m: Message) => m.id === newMessage.id);
          if (exists) return existing;
          return { messages: [newMessage, ...existing.messages] };
        }
      );
    },
  });

  const [sendMessage] = useMutation(SEND_MESSAGE_MUTATION, {
    onError: (err) => console.error('Send failed:', err),
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!content.trim()) return;
    await sendMessage({ variables: { channelId, content } });
    setContent('');
  };

  if (loading) return <div>Loading messages...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <div>
        {data?.messages?.map((msg: Message) => (
          <div key={msg.id}>
            <strong>{msg.sender.name}</strong>: {msg.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Authentication in Subscriptions: connectionParams and Context

WebSocket connections cannot send custom HTTP headers after the initial handshake. Authentication is typically handled via connectionParams. Here is the full authentication flow:

// Server: src/server.ts — WebSocket authentication
import { useServer } from 'graphql-ws/lib/use/ws';
import jwt from 'jsonwebtoken';

const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // Extract token from connectionParams (sent by client)
      const authHeader = ctx.connectionParams?.authorization as string | undefined;

      if (!authHeader) {
        return { user: null };
      }

      try {
        const token = authHeader.replace('Bearer ', '');
        const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
        const user = await db.users.findUnique({ where: { id: decoded.sub } });
        return { user };
      } catch (err) {
        // Invalid token — return null user, let resolvers handle authorization
        console.error('WebSocket auth error:', err);
        return { user: null };
      }
    },
    onConnect: async (ctx) => {
      // Optional: reject connection entirely if no valid token
      const authHeader = ctx.connectionParams?.authorization as string | undefined;
      if (!authHeader) {
        // Return true to allow anonymous connections,
        // or throw an error to reject:
        // throw new Error('Unauthorized');
        return true;
      }
      return true;
    },
  },
  wsServer
);

// Client: src/lib/apollo-client.ts — sending auth token
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    connectionParams: async () => {
      // This function is called on each connection attempt
      const token = await getToken(); // Your token retrieval logic
      return {
        authorization: token ? 'Bearer ' + token : '',
      };
    },
    // Re-establish connection when token changes
    shouldRetry: () => true,
  })
);

Checking Authentication in Subscription Resolvers

// src/resolvers.ts — authorization in subscription resolvers
export const resolvers = {
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        (_, args, context) => {
          // Check authentication before subscribing
          if (!context.user) {
            throw new Error('You must be logged in to subscribe to messages');
          }

          // Check channel membership authorization
          if (!context.user.channelIds.includes(args.channelId)) {
            throw new Error('You do not have access to this channel');
          }

          return pubsub.asyncIterator([EVENTS.MESSAGE_SENT]);
        },
        (payload, variables) =>
          payload.messageSent.channelId === variables.channelId
      ),
    },
  },
};

Scaling Subscriptions: Redis PubSub and Horizontal Scaling

The in-memory PubSub works only within a single Node.js process. In a multi-instance deployment, clients connected to instance A will not receive events published by instance B. The solution is Redis PubSub, which broadcasts events across all instances via Redis channels.

# Install Redis PubSub for GraphQL
npm install graphql-redis-subscriptions ioredis
// src/pubsub.ts — Redis-backed PubSub
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const redisOptions: Redis.RedisOptions = {
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
  retryDelayOnFailover: 100,
  enableOfflineQueue: false,
  maxRetriesPerRequest: 3,
  lazyConnect: true,
};

export const pubsub = new RedisPubSub({
  // Use separate connections for publisher and subscriber
  // (Redis requires separate connections for pub and sub)
  publisher: new Redis(redisOptions),
  subscriber: new Redis(redisOptions),
});

// Test the connection
pubsub.getSubscriber().on('connect', () => {
  console.log('Redis subscriber connected');
});
pubsub.getPublisher().on('connect', () => {
  console.log('Redis publisher connected');
});

Horizontal Scaling Architecture

Load Balancer (Nginx / AWS ALB)
    |              |              |
    |              |              |
Server A       Server B       Server C
(WebSocket)   (WebSocket)   (WebSocket)
    |              |              |
    +------+--------+------+------+
           |                |
    Redis PubSub      Redis PubSub
    (subscriber)      (publisher)
           |                |
           +-------+--------+
                   |
              Redis Server
              (event bus)

Event flow:
1. Client connected to Server A sends mutation
2. Server A publishes event to Redis channel
3. Redis broadcasts to all subscribers (A, B, C)
4. Each server delivers to its locally connected subscribers

When using Redis PubSub, ensure your load balancer is configured with WebSocket sticky sessions, or use an application-level routing strategy that keeps WebSocket connections on the same server instance throughout their lifetime.

Real-time Platform Comparison: Apollo vs Hasura vs Supabase

Beyond building your own subscription server, several managed platforms provide out-of-the-box real-time capabilities:

FeatureApollo (DIY)HasuraSupabase
ImplementationManual codeAuto-generatedAuto-generated
TransportWebSocket / SSEWebSocketWebSocket / Realtime
DB real-timeVia code triggersPostgreSQL pollingPostgreSQL WAL
Custom logicFull controlLimited (Actions)Limited (Edge Functions)
ScalabilityManual (needs Redis)Built-in scalingBuilt-in scaling
Auth / PermissionsFully customRole-basedRow-level security
Best forComplex custom logicFast CRUD real-timeFull-stack rapid dev
PricingInfra costsFree + paid tiersFree + paid tiers

Server-Sent Events (SSE) as a WebSocket Alternative

Server-Sent Events is an HTTP-based unidirectional push protocol. Compared to WebSockets, SSE is more proxy-friendly and supports automatic reconnection. The graphql-sse library implements GraphQL subscriptions over SSE.

WebSocket vs SSE Comparison

FeatureWebSocketSSE
DirectionBidirectionalServer to client only
Protocolws:// / wss://HTTP/1.1 or HTTP/2
Proxy supportRequires special configWorks natively
Auto-reconnectManual implementationBrowser built-in
Connection limitsUnlimitedHTTP/1.1: 6, HTTP/2: unlimited
Server complexitySeparate WS serverPlain HTTP endpoint
Best forInteractive real-timeRead-only data streams

graphql-sse Server Implementation

// SSE-based GraphQL subscriptions with graphql-sse
// npm install graphql-sse

import express from 'express';
import { createHandler } from 'graphql-sse/lib/use/express';
import { schema } from './schema';

const app = express();

// Mount SSE handler at /graphql/stream
app.use(
  '/graphql/stream',
  createHandler({
    schema,
    context: async (req) => {
      // Standard HTTP auth works here — cookies and Authorization headers
      const token = req.headers.authorization?.replace('Bearer ', '');
      const user = token ? await verifyToken(token) : null;
      return { user };
    },
  })
);

app.listen(4000, () => {
  console.log('SSE GraphQL server at http://localhost:4000/graphql/stream');
});
// SSE client with Apollo Client
// npm install graphql-sse @apollo/client

import { ApolloClient, InMemoryCache, split } from '@apollo/client';
import { createClient } from 'graphql-sse';
import { SSELink } from './sse-link'; // Custom link

// Create SSE link (see graphql-sse docs for SSELink implementation)
const sseLink = new SSELink({
  url: 'http://localhost:4000/graphql/stream',
  headers: () => ({
    authorization: 'Bearer ' + localStorage.getItem('auth_token'),
  }),
});

export const client = new ApolloClient({
  link: split(
    ({ query }) => {
      const def = getMainDefinition(query);
      return def.kind === 'OperationDefinition' && def.operation === 'subscription';
    },
    sseLink,
    httpLink
  ),
  cache: new InMemoryCache(),
});

Error Handling and Reconnection

Production subscriptions must gracefully handle network interruptions, server restarts, and expired authentication. Here is a comprehensive error handling strategy:

// src/lib/apollo-client.ts — robust reconnection setup
import { createClient } from 'graphql-ws';

const wsClient = createClient({
  url: 'ws://localhost:4000/graphql',
  connectionParams: () => ({
    authorization: 'Bearer ' + localStorage.getItem('auth_token'),
  }),

  // Retry configuration
  retryAttempts: 10,
  retryWait: async (retries) => {
    // Exponential backoff: 1s, 2s, 4s, 8s... up to 30s
    const delay = Math.min(1000 * Math.pow(2, retries), 30000);
    await new Promise((resolve) => setTimeout(resolve, delay));
  },
  shouldRetry: (errOrCloseEvent) => {
    // Retry on network errors and abnormal closes
    // Do not retry on auth errors (close code 4400)
    if (errOrCloseEvent instanceof CloseEvent) {
      return errOrCloseEvent.code !== 4400 && errOrCloseEvent.code !== 4401;
    }
    return true;
  },

  // Lifecycle hooks
  on: {
    connecting: () => console.log('WS: connecting...'),
    connected: (socket, payload, wasRetry) => {
      console.log('WS: connected', wasRetry ? '(after retry)' : '');
    },
    closed: (event) => {
      console.log('WS: closed', event);
    },
    error: (err) => {
      console.error('WS: error', err);
    },
    ping: (received) => {
      if (!received) console.log('WS: ping sent');
    },
    pong: (received) => {
      if (received) console.log('WS: pong received');
    },
  },

  // Keep-alive: server sends pings every 10s
  keepAlive: 10_000,

  // Reconnect on any close
  lazyCloseTimeout: 3_000,
});

Server-Side Error Handling

// src/server.ts — server-side error handling for subscriptions
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => ({ /* ... */ }),
    onError: (ctx, message, errors) => {
      // Called when a subscription throws an error
      console.error('Subscription error:', errors);
    },
    onComplete: (ctx, message) => {
      // Called when client unsubscribes
      console.log('Subscription completed', message.id);
    },
    // Keep-alive ping interval (ms)
    // Clients that do not respond to pings will be disconnected
    keepAlive: 10_000,
  },
  wsServer
);

// Graceful shutdown on process signals
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing WebSocket server...');
  await serverCleanup.dispose();
  await server.stop();
  process.exit(0);
});

Subscription + Query/Mutation Integration Patterns

Subscriptions are typically combined with queries and mutations: queries load initial state, subscriptions receive subsequent updates. Here are common integration patterns:

Pattern 1: subscribeToMore (Incremental Updates)

// Use subscribeToMore to add real-time updates to an existing query
const { data, loading, subscribeToMore } = useQuery(GET_MESSAGES, {
  variables: { channelId },
});

// Set up subscription after initial query loads
React.useEffect(() => {
  const unsubscribe = subscribeToMore({
    document: MESSAGE_SENT_SUBSCRIPTION,
    variables: { channelId },
    updateQuery: (prev, { subscriptionData }) => {
      if (!subscriptionData.data) return prev;
      const newMessage = subscriptionData.data.messageSent;

      return {
        ...prev,
        messages: [newMessage, ...prev.messages],
      };
    },
  });

  // Cleanup on unmount or channelId change
  return () => unsubscribe();
}, [channelId, subscribeToMore]);

Pattern 2: Optimistic Updates + Subscription Confirmation

// Optimistic UI: show message immediately, confirm via subscription
const [sendMessage] = useMutation(SEND_MESSAGE_MUTATION, {
  optimisticResponse: {
    sendMessage: {
      __typename: 'Message',
      id: 'temp-' + Date.now(),  // Temporary ID
      content: pendingContent,
      createdAt: new Date().toISOString(),
      sender: currentUser,
    },
  },
  update: (cache, { data }) => {
    // Update cache with actual response (replaces optimistic response)
    cache.updateQuery(
      { query: GET_MESSAGES, variables: { channelId } },
      (existing) => ({
        messages: [data.sendMessage, ...existing.messages.filter(
          (m) => !m.id.startsWith('temp-')
        )],
      })
    );
  },
});

Pattern 3: Cursor Pagination + Real-time Append

// Combine paginated query with real-time subscription
// This pattern is common for news feeds and activity streams

const GET_POSTS = gql`
  query GetPosts($cursor: String) {
    posts(after: $cursor, first: 20) {
      edges {
        node { id title author { name } createdAt }
        cursor
      }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

const NEW_POST_SUBSCRIPTION = gql`
  subscription OnNewPost {
    postCreated {
      id title author { name } createdAt
    }
  }
`;

// New posts arrive via subscription, older posts via pagination
useSubscription(NEW_POST_SUBSCRIPTION, {
  onData: ({ client, data }) => {
    const newPost = data.data?.postCreated;
    if (!newPost) return;

    // Prepend to the existing paginated list
    client.cache.updateQuery(
      { query: GET_POSTS, variables: { cursor: undefined } },
      (existing) => ({
        posts: {
          ...existing.posts,
          edges: [
            { node: newPost, cursor: newPost.id, __typename: 'PostEdge' },
            ...existing.posts.edges,
          ],
        },
      })
    );
  },
});

Performance Optimization: Filtering, Batching, and Connection Management

Unoptimized subscriptions can cause unnecessary data transfer and server load. Here are key performance optimization techniques:

Server-Side Event Filtering

// Use withFilter to avoid sending irrelevant events over the wire
import { withFilter } from 'graphql-subscriptions';

export const resolvers = {
  Subscription: {
    // Only send messages to users in the correct channel AND with proper permissions
    messageSent: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([EVENTS.MESSAGE_SENT]),
        async (payload, variables, context) => {
          // Filter 1: channel match
          if (payload.messageSent.channelId !== variables.channelId) return false;

          // Filter 2: user has access to this channel
          const hasAccess = await db.channelMembers.findFirst({
            where: {
              channelId: variables.channelId,
              userId: context.user?.id,
            },
          });
          return !!hasAccess;
        }
      ),
    },

    // Batch multiple notifications into a single event
    notificationsBatch: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([EVENTS.NOTIFICATION_BATCH]),
        (payload, variables) => payload.userId === variables.userId
      ),
    },
  },
};

// Publish batched events (e.g., debounce rapid updates)
let pendingNotifications: Notification[] = [];
let batchTimer: NodeJS.Timeout | null = null;

function scheduleNotificationBatch(userId: string, notification: Notification) {
  pendingNotifications.push(notification);

  if (batchTimer) clearTimeout(batchTimer);

  // Batch notifications within a 100ms window
  batchTimer = setTimeout(() => {
    pubsub.publish(EVENTS.NOTIFICATION_BATCH, {
      notificationsBatch: { userId, notifications: pendingNotifications },
    });
    pendingNotifications = [];
    batchTimer = null;
  }, 100);
}

Connection Limits and Backpressure

// Limit concurrent subscriptions per user
const userSubscriptionCount = new Map<string, number>();
const MAX_SUBSCRIPTIONS_PER_USER = 10;

const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      const user = await getUser(ctx.connectionParams);
      return { user };
    },
    onSubscribe: async (ctx, message) => {
      const userId = (ctx.extra as any).user?.id;
      if (!userId) return; // Anonymous allowed

      const count = userSubscriptionCount.get(userId) || 0;
      if (count >= MAX_SUBSCRIPTIONS_PER_USER) {
        return [new Error('Too many subscriptions. Maximum is ' + MAX_SUBSCRIPTIONS_PER_USER)];
      }
      userSubscriptionCount.set(userId, count + 1);
    },
    onComplete: (ctx, message) => {
      const userId = (ctx.extra as any).user?.id;
      if (!userId) return;

      const count = userSubscriptionCount.get(userId) || 1;
      userSubscriptionCount.set(userId, count - 1);
    },
    onDisconnect: (ctx) => {
      const userId = (ctx.extra as any).user?.id;
      if (userId) userSubscriptionCount.delete(userId);
    },
  },
  wsServer
);

Practical Use Cases and Complete Examples

Use Case 1: Real-time Collaborative Cursor Tracking

// Schema for collaborative cursor positions
const COLLAB_SCHEMA = gql`
  type CursorPosition {
    userId: ID!
    userName: String!
    x: Float!
    y: Float!
    documentId: ID!
  }

  type Mutation {
    updateCursor(documentId: ID!, x: Float!, y: Float!): CursorPosition!
  }

  type Subscription {
    cursorMoved(documentId: ID!): CursorPosition!
  }
`;

// Throttle cursor updates to avoid flooding (max 10 updates/second)
const throttledPublish = throttle((documentId: string, position: CursorPosition) => {
  pubsub.publish('CURSOR_MOVED_' + documentId, { cursorMoved: position });
}, 100);

const collabResolvers = {
  Mutation: {
    updateCursor: async (_, { documentId, x, y }, { user }) => {
      const position = { userId: user.id, userName: user.name, x, y, documentId };
      throttledPublish(documentId, position);
      return position;
    },
  },
  Subscription: {
    cursorMoved: {
      subscribe: withFilter(
        (_, args) => pubsub.asyncIterator(['CURSOR_MOVED_' + args.documentId]),
        // Don't send users their own cursor back
        (payload, _, context) => payload.cursorMoved.userId !== context.user?.id
      ),
    },
  },
};

Use Case 2: Real-time Dashboard Metrics

// Schema for live metrics dashboard
const METRICS_SCHEMA = gql`
  type MetricUpdate {
    name: String!
    value: Float!
    unit: String!
    timestamp: String!
    trend: String! # "up" | "down" | "stable"
  }

  type Subscription {
    metricsUpdated(names: [String!]!): MetricUpdate!
  }
`;

// Publish metrics on a schedule or when they change
setInterval(async () => {
  const metrics = await collectSystemMetrics();

  for (const metric of metrics) {
    await pubsub.publish('METRICS_UPDATED', { metricsUpdated: metric });
  }
}, 5000); // Update every 5 seconds

const metricsResolvers = {
  Subscription: {
    metricsUpdated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['METRICS_UPDATED']),
        (payload, variables) =>
          variables.names.includes(payload.metricsUpdated.name)
      ),
    },
  },
};

Key Takeaways

  • Always use graphql-ws (the graphql-transport-ws protocol); avoid the deprecated subscriptions-transport-ws
  • Use a split link to route HTTP traffic (queries/mutations) and WebSocket traffic (subscriptions) correctly
  • Pass auth tokens via connectionParams; verify them in the onConnect callback or context function
  • Use withFilter to filter events server-side and avoid sending irrelevant data over the wire
  • Use in-memory PubSub for single-process development; always use Redis PubSub for multi-instance production
  • Configure retryAttempts with exponential backoff for automatic reconnection
  • Use SSE (graphql-sse) as an alternative when WebSocket connections are blocked by proxies
  • Implement connection limits and keep-alive pings to prevent resource leaks

Frequently Asked Questions

What is a GraphQL subscription and how does it differ from queries and mutations?

A GraphQL subscription is a long-lived operation that pushes data from the server to the client whenever a specific event occurs. Unlike queries (one-time data fetch) and mutations (one-time data change), subscriptions maintain a persistent connection — typically over WebSocket — and receive multiple responses over time. They follow a publish/subscribe pattern: the server publishes events to a topic, and subscribed clients receive those events automatically.

What is the difference between graphql-ws and subscriptions-transport-ws?

graphql-ws is the modern, actively maintained library implementing the graphql-transport-ws protocol. subscriptions-transport-ws is the older, deprecated library that implemented the graphql-ws protocol (confusingly named). For all new projects, use graphql-ws. The subscriptions-transport-ws library is no longer maintained, has known security issues, and Apollo Server 4 removed built-in support for it. Migrating from the old library requires updating both server and client code to use the new graphql-transport-ws protocol.

How do I authenticate WebSocket connections for GraphQL subscriptions?

WebSocket connections cannot send custom HTTP headers after the initial handshake, so authentication is typically handled via connection parameters. On the client, pass the JWT in the connectionParams option of GraphQLWsLink. On the server, extract and verify the token in the onConnect callback of useServer. Once verified, attach the user to the context so all subscription resolvers can access it. Alternatively, some implementations use cookies for authentication since they are automatically sent with WebSocket upgrade requests.

What is PubSub in GraphQL subscriptions and when should I use Redis PubSub?

PubSub (Publish/Subscribe) is the messaging pattern used to deliver events to subscription resolvers. The in-memory PubSub from @graphql-subscriptions works only within a single Node.js process — it does not work across multiple server instances. For production deployments with multiple servers or horizontal scaling, use a distributed PubSub backed by Redis (graphql-redis-subscriptions) or another message broker. Redis PubSub uses Redis channels to broadcast events across all server instances, ensuring all subscribed clients receive events regardless of which server they are connected to.

How does the useSubscription hook work in Apollo Client?

The useSubscription hook from @apollo/client subscribes to a GraphQL subscription and returns { data, loading, error } reactive state. It establishes a WebSocket connection via GraphQLWsLink when the component mounts and automatically cleans up when the component unmounts. The hook re-renders the component every time new data arrives from the server. You can also use the onData callback option for side effects like updating a list or showing notifications without relying on re-renders.

What are Server-Sent Events (SSE) and when should I use them instead of WebSockets for GraphQL?

Server-Sent Events (SSE) is an HTTP-based protocol for unidirectional server-to-client streaming. Unlike WebSockets, SSE works over standard HTTP/1.1 and HTTP/2, automatically reconnects on disconnect, passes through all HTTP proxies and load balancers, and does not require special firewall rules. The graphql-sse library implements GraphQL subscriptions over SSE. Use SSE when you need subscriptions through restrictive corporate proxies, when you only need server-to-client data push (not bidirectional), or in environments where WebSocket connections are blocked.

How do I filter subscription events to send only relevant data to each subscriber?

Use the withFilter wrapper from graphql-subscriptions to add subscriber-specific filtering to your subscription resolver. The withFilter function wraps the asyncIterator function and takes a second argument — a filter function that receives the payload and the subscription variables, returning true to deliver the event or false to skip it. For example, in a chat app you can filter messages so each subscriber only receives messages from the channels they subscribed to. This filtering happens server-side before sending data over the wire, reducing unnecessary network traffic.

How do I handle reconnection and errors in GraphQL subscriptions on the client?

The graphql-ws client library has built-in reconnection logic with configurable retry attempts and delays via the retryAttempts and shouldRetry options. Configure these in the createClient call for GraphQLWsLink. For error handling in Apollo Client, the error state returned by useSubscription contains network errors and GraphQL errors. Implement a subscription keep-alive by setting the keepAlive option (in milliseconds) on the server useServer call. On the client, use the onNonLazyError and onError callbacks in createClient to log errors and trigger reconnection logic.

Production Deployment Checklist

GraphQL Subscriptions Production Checklist
==========================================

Transport & Protocol
[ ] Using graphql-ws (not subscriptions-transport-ws)
[ ] TLS/SSL enabled (wss:// in production)
[ ] CORS configured for WebSocket upgrade requests
[ ] WebSocket path secured (/graphql not exposed to crawlers)

Authentication & Authorization
[ ] JWT verified in WebSocket context function
[ ] Unauthorized connections rejected (code 4401)
[ ] Per-subscription authorization in resolver subscribe()
[ ] Token expiry handled (reconnect with fresh token)

Scalability
[ ] Redis PubSub configured (not in-memory PubSub)
[ ] Sticky sessions enabled in load balancer
[ ] Redis cluster or Sentinel for high availability
[ ] Connection limits per user enforced

Performance
[ ] withFilter() used to minimize unnecessary events
[ ] Event batching for high-frequency updates
[ ] Keep-alive ping interval configured (10-30s)
[ ] Subscription payload size monitored

Reliability
[ ] Automatic client reconnection configured
[ ] Exponential backoff on retry
[ ] Graceful server shutdown (serverCleanup.dispose())
[ ] Error logging and alerting configured

Monitoring
[ ] Active WebSocket connection count tracked
[ ] Subscription latency measured (p50, p95, p99)
[ ] Redis PubSub lag monitored
[ ] Connection error rate alerted

Conclusion

GraphQL subscriptions provide powerful real-time data capabilities for modern applications. By combining graphql-ws and Apollo, you can build everything from simple chat apps to complex collaborative tools. The keys to success are: choosing the right transport (graphql-ws), designing a proper PubSub architecture (Redis for production), implementing robust authentication, and configuring automatic client reconnection.

As your application scales, subscription performance optimization becomes critical: use withFilter to reduce unnecessary data transfer, enforce connection limits to prevent resource abuse, and rely on Redis PubSub for event consistency across multiple server instances. Mastering these patterns will equip you to build truly production-grade real-time GraphQL applications.

𝕏 Twitterin LinkedIn
Czy to było pomocne?

Bądź na bieżąco

Otrzymuj cotygodniowe porady i nowe narzędzia.

Bez spamu. Zrezygnuj kiedy chcesz.

Try These Related Tools

{ }JSON Formatter🔌API Tester

Related Articles

GraphQL Complete Guide: Schema, Apollo, DataLoader, and Performance

Master GraphQL with this complete guide. Covers schema design, Apollo Server/Client, queries/mutations, DataLoader, subscriptions, authentication, code generation, and performance optimization.

WebSocket Complete Guide: Real-Time Communication with ws and Socket.io

Master WebSocket real-time communication. Complete guide with Browser API, Node.js ws, Socket.io, React hooks, Python websockets, Go gorilla/websocket, authentication, scaling, and error handling.

API Testing: Complete Guide with cURL, Supertest, and k6

Master API testing with this complete guide. Covers HTTP methods, cURL, fetch/axios, Postman/Newman, supertest, Python httpx, mock servers, contract testing, k6 load testing, and OpenAPI documentation.