MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

GraphQL Subscriptions: Real-Time with WebSocket

Implement GraphQL subscriptions: Apollo Server, WebSocket transport, and scaling.

GraphQLSubscriptionsWebSocketReal-Time

By MinhVo

Introduction

Building real-time features—live feeds, instant notifications, collaborative editing—requires persistent connections between client and server. GraphQL Subscriptions provide a first-class way to implement these features while maintaining the type safety and schema integration that makes GraphQL powerful.

In this guide, we'll implement GraphQL subscriptions with Apollo Server 4 and the modern graphql-ws protocol. We'll cover everything from basic setup to production scaling patterns, including authentication, filtering, and distributed PubSub systems.

WebSocket real-time architecture

Understanding GraphQL Subscriptions: Core Concepts

The Subscription Lifecycle

A GraphQL subscription follows a distinct lifecycle that differs from queries and mutations. While queries and mutations are request-response operations over HTTP, subscriptions are long-lived operations over WebSocket:

  1. Connection: Client opens a WebSocket connection and sends connection_init
  2. Authentication: Server validates credentials and sends connection_ack
  3. Subscribe: Client sends a subscription operation
  4. Execute: Server registers the subscription and begins listening for events
  5. Push: When events occur, server executes the subscription resolver and pushes results
  6. Complete: Either client unsubscribes or the server terminates the subscription

This lifecycle means your server must handle both HTTP (for queries/mutations) and WebSocket (for subscriptions) traffic, often on the same port.

The graphql-ws Protocol

The graphql-ws library implements the WebSocket subprotocol for GraphQL. It replaces the older subscriptions-transport-ws with a more secure, performant, and specification-compliant implementation:

// Messages exchanged between client and server
 
// Client → Server
{ type: "connection_init", payload?: Record<string, unknown> }
{ type: "subscribe", id: string, payload: { query: string, variables?: object } }
{ type: "complete", id: string }
 
// Server → Client
{ type: "connection_ack" }
{ type: "next", id: string, payload: { data: object } }
{ type: "error", id: string, payload: Array<{ message: string }> }
{ type: "complete", id: string }

PubSub Systems

The PubSub (Publish-Subscribe) pattern is the backbone of subscription event delivery. When a mutation modifies data, it publishes an event to a topic. All subscriptions listening to that topic receive the event and execute their resolvers.

For single-server development, the built-in PubSub from graphql-subscriptions works fine. For production with multiple server instances, you need a distributed PubSub backed by Redis, NATS, or Kafka to ensure events reach all servers.

Architecture and Design Patterns

Dual-Transport Architecture

Apollo Server supports both HTTP and WebSocket on the same server. HTTP handles queries and mutations, while WebSocket handles subscriptions:

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import express from "express";
import { createServer } from "http";
 
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({ path: "/graphql" });
const wsServerCleanup = useServer({ schema }, wsServer);
 
// Apollo Server for queries/mutations
const apolloServer = new ApolloServer({
  schema,
  plugins: [{
    async serverWillStart() {
      return {
        async drainServer() {
          await wsServerCleanup.dispose();
        },
      };
    },
  }],
});
 
// Express app
const app = express();
const httpServer = createServer(app);
 
await apolloServer.start();
app.use("/graphql", express.json(), expressMiddleware(apolloServer));
 
httpServer.listen(4000);

Event-Driven Resolver Pattern

Separate event publishing (in mutations) from event consumption (in subscriptions) using a shared PubSub instance:

import { PubSub } from "graphql-subscriptions";
 
const pubsub = new PubSub();
 
const resolvers = {
  Mutation: {
    createComment: async (_, { input }, { db, user }) => {
      const comment = await db.comment.create({
        data: { ...input, authorId: user.id },
        include: { author: true },
      });
 
      // Publish event for real-time subscribers
      pubsub.publish("COMMENT_CREATED", {
        commentCreated: comment,
        postId: input.postId,
      });
 
      return comment;
    },
  },
 
  Subscription: {
    commentCreated: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("COMMENT_CREATED"),
        (payload, variables) => payload.postId === variables.postId
      ),
      resolve: (payload) => payload.commentCreated,
    },
  },
};

Authentication Context Pattern

Authenticate WebSocket connections once during connection_init and store the user in the connection context:

const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // Authenticate during connection_init
      const token = ctx.connectionParams?.authToken as string;
      if (!token) {
        throw new Error("Authentication required");
      }
 
      const user = await verifyToken(token);
      if (!user) {
        throw new Error("Invalid token");
      }
 
      return { user, pubsub, db };
    },
  },
  wsServer
);

Subscription filtering architecture

Step-by-Step Implementation

Project Setup

Install the required packages:

npm install @apollo/server express graphql graphql-ws ws \
  graphql-subscriptions @graphql-tools/schema \
  @apollo/server express
 
npm install -D @types/ws @types/express

Defining the Schema

Create a schema with queries, mutations, and subscriptions:

scalar DateTime
 
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  createdAt: DateTime!
}
 
type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}
 
type User {
  id: ID!
  name: String!
  avatar: String
}
 
type Query {
  post(id: ID!): Post
  posts(limit: Int, offset: Int): [Post!]!
}
 
type Mutation {
  createComment(input: CreateCommentInput!): Comment!
  likePost(postId: ID!): Post!
}
 
type Subscription {
  commentCreated(postId: ID!): Comment!
  postLiked(postId: ID!): Post!
  newPost: Post!
}
 
input CreateCommentInput {
  postId: ID!
  content: String!
}

Implementing the Server

Create a complete server with all three transports:

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { PubSub, withFilter } from "graphql-subscriptions";
import express from "express";
import { createServer } from "http";
 
const pubsub = new PubSub();
 
// Resolvers
const resolvers = {
  Query: {
    post: async (_, { id }, { db }) => db.post.findUnique({ where: { id } }),
    posts: async (_, args, { db }) => db.post.findMany(args),
  },
 
  Post: {
    author: (post, _, { db }) => db.user.findUnique({ where: { id: post.authorId } }),
    comments: (post, _, { db }) => db.comment.findMany({ where: { postId: post.id } }),
  },
 
  Mutation: {
    createComment: async (_, { input }, { user, db }) => {
      const comment = await db.comment.create({
        data: { ...input, authorId: user.id },
        include: { author: true },
      });
 
      pubsub.publish("COMMENT_CREATED", {
        commentCreated: comment,
        postId: input.postId,
      });
 
      return comment;
    },
 
    likePost: async (_, { postId }, { user, db }) => {
      await db.postLike.create({
        data: { postId, userId: user.id },
      });
 
      const post = await db.post.findUnique({ where: { id: postId } });
 
      pubsub.publish("POST_LIKED", { postLiked: post, postId });
 
      return post;
    },
  },
 
  Subscription: {
    commentCreated: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("COMMENT_CREATED"),
        (payload, variables) => payload.postId === variables.postId
      ),
      resolve: (payload) => payload.commentCreated,
    },
 
    postLiked: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("POST_LIKED"),
        (payload, variables) => payload.postId === variables.postId
      ),
      resolve: (payload) => payload.postLiked,
    },
 
    newPost: {
      subscribe: () => pubsub.asyncIterableIterator("NEW_POST"),
      resolve: (payload) => payload.newPost,
    },
  },
};
 
// Schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// WebSocket server
const wsServer = new WebSocketServer({ path: "/graphql" });
 
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authToken as string;
      const user = token ? await verifyToken(token) : null;
      return { user, pubsub, db };
    },
  },
  wsServer
);
 
// Apollo Server
const apolloServer = new ApolloServer({
  schema,
  plugins: [{
    async serverWillStart() {
      return {
        async drainServer() {
          await serverCleanup.dispose();
        },
      };
    },
  }],
});
 
// Express setup
const app = express();
const httpServer = createServer(app);
 
await apolloServer.start();
app.use("/graphql", express.json(), expressMiddleware(apolloServer, {
  context: async ({ req }) => ({
    user: await verifyToken(req.headers.authorization),
    pubsub,
    db,
  }),
}));
 
httpServer.listen(4000, () => {
  console.log("Server running at http://localhost:4000/graphql");
  console.log("Subscriptions at ws://localhost:4000/graphql");
});

Client-Side Implementation

Set up Apollo Client with both HTTP and WebSocket links:

import { ApolloClient, InMemoryCache, HttpLink, split } from "@apollo/client";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import { createClient } from "graphql-ws";
 
// HTTP link for queries and mutations
const httpLink = new HttpLink({
  uri: "http://localhost:4000/graphql",
});
 
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
  createClient({
    url: "ws://localhost:4000/graphql",
    connectionParams: () => ({
      authToken: localStorage.getItem("token"),
    }),
    retryAttempts: Infinity,
    shouldRetry: () => true,
  })
);
 
// Split traffic based on operation type
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(),
});

Using Subscriptions in React Components

import { useSubscription, useMutation, gql } from "@apollo/client";
import { useState } from "react";
 
const COMMENT_CREATED = gql`
  subscription OnCommentCreated($postId: ID!) {
    commentCreated(postId: $postId) {
      id
      content
      author { id name avatar }
      createdAt
    }
  }
`;
 
const CREATE_COMMENT = gql`
  mutation CreateComment($input: CreateCommentInput!) {
    createComment(input: $input) {
      id
      content
      author { id name }
    }
  }
`;
 
function PostComments({ postId }: { postId: string }) {
  const [comments, setComments] = useState<Comment[]>([]);
 
  useSubscription(COMMENT_CREATED, {
    variables: { postId },
    onData: ({ data }) => {
      setComments((prev) => [...prev, data.data.commentCreated]);
    },
  });
 
  const [createComment] = useMutation(CREATE_COMMENT);
 
  return (
    <div>
      <CommentList comments={comments} />
      <CommentForm
        onSubmit={(content) =>
          createComment({ variables: { input: { postId, content } } })
        }
      />
    </div>
  );
}

Client-server subscription flow

Real-World Use Cases

Live Comment System

A blog platform uses subscriptions to display new comments in real-time. When a user posts a comment via mutation, the subscription pushes it to all readers viewing that post. The system uses post-level filtering so users only receive comments for posts they're viewing.

Real-Time Collaboration

A project management tool uses subscriptions for live updates to task boards. When one user moves a task, all other users see the change instantly. Presence subscriptions show who's currently viewing each board.

Live Sports Scores

A sports app subscribes to score updates for games in progress. The subscription includes game-level filtering and delivers score changes, play-by-play updates, and statistics in real-time. Clients maintain local state that's updated incrementally as events arrive.

Monitoring Dashboard

A DevOps dashboard subscribes to server metrics, deployment status, and alert notifications. Each widget subscribes to its specific metric type, and the server aggregates high-frequency data points before pushing them to clients.

Best Practices for Production

  1. Use graphql-ws over subscriptions-transport-ws: The newer protocol is more secure (no introspection over WebSocket by default) and performant.

  2. Authenticate at connection level: Verify credentials once during connection_init and store in context. Don't re-authenticate on every subscription.

  3. Use distributed PubSub for multi-instance deployments: The in-memory PubSub doesn't share events between processes. Use Redis:

import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
 
const pubsub = new RedisPubSub({
  publisher: new Redis(process.env.REDIS_URL),
  subscriber: new Redis(process.env.REDIS_URL),
});
  1. Implement connection keepalive: Prevent proxy timeouts and detect dead connections:
const wsServer = new WebSocketServer({
  path: "/graphql",
});
 
useServer({
  schema,
  // Send keepalive every 30 seconds
  onComplete: () => { /* cleanup */ },
}, wsServer);
  1. Filter subscriptions at the server level: Use withFilter to ensure clients only receive events they're authorized to see and have subscribed to.

  2. Rate-limit subscription events: For high-frequency events, implement server-side batching to avoid overwhelming clients:

// Debounce high-frequency events
import { debounce } from "lodash";
 
const batchPublish = debounce((events) => {
  pubsub.publish("BATCH_UPDATE", { events });
}, 100);
  1. Implement graceful shutdown: Drain WebSocket connections before shutting down the server:
process.on("SIGTERM", async () => {
  await serverCleanup.dispose();
  await apolloServer.stop();
  httpServer.close();
});
  1. Monitor subscription metrics: Track active connections, message rates, and error rates to identify issues before they affect users.

Common Pitfalls and Solutions

PitfallImpactSolution
Using in-memory PubSub in productionEvents lost between server instancesUse Redis or NATS PubSub
Missing authentication on WebSocketUnauthorized access to real-time dataVerify tokens in connection_init
No connection keepaliveProxy timeouts, zombie connectionsImplement ping/pong at 30s intervals
Unbounded subscription growthMemory leaks, server overloadSet max subscriptions per connection
Client not handling reconnectionLost updates after disconnectImplement reconnection with exponential backoff

Performance Optimization

Optimize subscription performance by batching events, using efficient serialization, and minimizing resolver execution:

// Use DataLoader in subscription resolvers
const resolvers = {
  Subscription: {
    commentCreated: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("COMMENT_CREATED"),
        (payload, variables) => payload.postId === variables.postId
      ),
      resolve: async (payload, _, { loaders }) => {
        // Batch author loading
        const author = await loaders.user.load(payload.commentCreated.authorId);
        return { ...payload.commentCreated, author };
      },
    },
  },
};

Comparison with Alternatives

FeatureGraphQL SubscriptionsWebSocket (raw)Server-Sent EventsLong Polling
Type safetyFullNoneNoneFull
Schema integrationYesNoNoYes
BidirectionalYesYesNoNo
ComplexityModerateHighLowLow
ScalabilityGoodGoodGoodPoor
Browser supportUniversalUniversalUniversalUniversal
ReconnectionManualManualBuilt-inN/A

Advanced Patterns

Subscription Deduplication

When multiple clients subscribe to the same topic with identical parameters, deduplicate to reduce server load:

class SubscriptionManager {
  private subscriptions = new Map<string, { count: number; iterator: AsyncIterator<any> }>();
 
  subscribe(topic: string, iteratorFactory: () => AsyncIterator<any>) {
    if (this.subscriptions.has(topic)) {
      this.subscriptions.get(topic)!.count++;
    } else {
      this.subscriptions.set(topic, { count: 1, iterator: iteratorFactory() });
    }
  }
 
  unsubscribe(topic: string) {
    const sub = this.subscriptions.get(topic);
    if (sub) {
      sub.count--;
      if (sub.count === 0) {
        sub.iterator.return?.();
        this.subscriptions.delete(topic);
      }
    }
  }
}

Subscription-Based Cache Invalidation

Use subscriptions to invalidate Apollo Client cache entries when data changes:

useSubscription(ORDER_UPDATED, {
  variables: { orderId },
  onData: ({ data }) => {
    // Update the cache with new order data
    cache.modify({
      id: `Order:${orderId}`,
      fields: {
        status: () => data.orderUpdated.status,
        total: () => data.orderUpdated.total,
      },
    });
  },
});

Testing Strategies

Test subscriptions using WebSocket test clients and mock PubSub:

import { createClient } from "graphql-ws";
import WebSocket from "ws";
 
describe("Comment subscription", () => {
  it("delivers new comments to subscribers", (done) => {
    const client = createClient({
      url: "ws://localhost:4000/graphql",
      webSocketImpl: WebSocket,
      connectionParams: { authToken: testToken },
    });
 
    const unsubscribe = client.subscribe(
      {
        query: `subscription { commentCreated(postId: "1") { id content } }`,
      },
      {
        next: (data) => {
          expect(data.data.commentCreated.content).toBe("Great post!");
          unsubscribe();
          done();
        },
        error: done,
        complete: () => {},
      }
    );
 
    // Create comment after subscription is active
    setTimeout(async () => {
      await server.executeOperation({
        query: `mutation { createComment(input: { postId: "1", content: "Great post!" }) { id } }`,
      });
    }, 100);
  });
});

Subscription Filtering and Authorization

Filtering subscription events at the server level prevents clients from receiving irrelevant data and reduces bandwidth consumption. Implement filtering logic in the subscription resolver by checking event properties against subscription arguments or user permissions before publishing. For example, a chat subscription might filter messages by room ID, while a notification subscription might filter by notification type or severity level.

const resolvers = {
  Subscription: {
    messageAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator('MESSAGE_ADDED'),
        (payload, variables, context) => {
          const message = payload.messageAdded;
          return (
            message.roomId === variables.roomId &&
            context.user.tenants.includes(message.tenantId)
          );
        }
      ),
    },
  },
};

Authorization for subscriptions requires validating the user's permissions at subscription creation time and on each published event. The initial subscription request should verify that the authenticated user has access to the requested resource. Subsequent event filtering should re-validate permissions because user roles may change between subscription creation and event delivery. Store the authenticated user context during the WebSocket handshake and attach it to each subscription for per-event authorization checks.

Scaling Subscriptions with Redis and Message Brokers

Running GraphQL subscriptions across multiple server instances requires a shared message broker to distribute events between servers. Redis Pub/Sub is the most common choice, where each server instance subscribes to relevant Redis channels and forwards events to its connected WebSocket clients. The graphql-redis-subscriptions package replaces the in-memory PubSub with a Redis-backed implementation that works transparently with existing subscription resolvers.

For higher throughput requirements, consider Apache Kafka or NATS as the message broker instead of Redis. Kafka provides durable message storage, consumer groups for horizontal scaling, and exactly-once delivery semantics. NATS offers ultra-low latency messaging with built-in clustering and message replay capabilities. The choice depends on your durability requirements, latency tolerance, and operational expertise with each system.

Deploy subscription servers behind a load balancer that supports WebSocket connection affinity. Sticky sessions ensure that each WebSocket connection routes to the same server for its lifetime, which is required for in-memory PubSub and simplifies Redis PubSub by reducing cross-instance message fan-out. Health checks should verify both HTTP and WebSocket connectivity to detect servers that accept new connections but fail to deliver subscription events.

Future Outlook

GraphQL subscriptions are evolving with the graphql-ws protocol becoming the standard. Future developments include subscription batching (receiving multiple events in a single message), incremental delivery for large payloads, and better integration with edge computing for ultra-low-latency applications. The subscription specification is also being extended to support filtering at the schema level.

Conclusion

GraphQL Subscriptions provide a powerful, type-safe way to build real-time features. The key takeaways are:

  1. Use graphql-ws protocol with Apollo Server 4 for modern, secure WebSocket subscriptions
  2. Authenticate at the connection level during connection_init
  3. Use distributed PubSub (Redis, NATS) for production multi-instance deployments
  4. Filter events at the server level using withFilter
  5. Implement connection keepalive, reconnection, and graceful shutdown for production reliability

Start with the in-memory PubSub for development, then migrate to Redis PubSub for production. Combined with proper authentication and filtering, GraphQL Subscriptions enable real-time features that keep users engaged and informed.