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 Data with WebSockets

Implement GraphQL subscriptions: real-time updates, WebSocket transport, and scaling.

GraphQLSubscriptionsWebSocketReal-Time

By MinhVo

Introduction

Real-time data is no longer a luxury—it's an expectation. Users want to see live chat messages, collaborative document edits, stock price updates, and notification badges without refreshing the page. GraphQL Subscriptions provide a type-safe, schema-integrated way to push real-time data from server to client using WebSocket connections.

Unlike polling (which wastes resources) or Server-Sent Events (which are unidirectional), GraphQL Subscriptions leverage WebSocket's bidirectional communication while maintaining GraphQL's type system, schema integration, and developer experience. In this guide, we'll implement a complete real-time system with subscriptions, explore scaling patterns, and learn production-ready techniques.

Real-time data flow architecture

Understanding GraphQL Subscriptions: Core Concepts

How Subscriptions Work

When a client creates a GraphQL subscription, the following sequence occurs:

  1. The client opens a WebSocket connection to the server
  2. The client sends a subscribe message with the GraphQL operation
  3. The server validates the operation, creates a subscription, and registers the client
  4. When the subscribed event occurs, the server executes the subscription query
  5. The server pushes the result to the client over the WebSocket
  6. Steps 4-5 repeat for each event until the client unsubscribes

This flow maintains a persistent connection, enabling sub-second latency for real-time updates. The server only executes the subscription query when an event fires, not on every tick.

The graphql-ws Protocol

The graphql-ws protocol (defined at graphql-ws) is the modern standard for GraphQL subscriptions over WebSocket, replacing the deprecated subscriptions-transport-ws. It defines message types for connection initialization, subscription management, and error handling:

// Client sends
{ type: "connection_init", payload: { authToken: "..." } }
{ type: "subscribe", id: "1", payload: { query: "subscription { ... }" } }
{ type: "complete", id: "1" }
 
// Server sends
{ type: "connection_ack" }
{ type: "next", id: "1", payload: { data: { ... } } }
{ type: "error", id: "1", payload: [{ message: "..." }] }
{ type: "complete", id: "1" }

PubSub Architecture

Subscriptions require a publish-subscribe (PubSub) system to decouple event producers (mutations) from event consumers (subscriptions). When a mutation modifies data, it publishes an event. The PubSub system distributes the event to all active subscriptions that match:

import { PubSub } from "graphql-subscriptions";
 
const pubsub = new PubSub();
 
// Publisher (in mutation resolver)
pubsub.publish("MESSAGE_SENT", { messageSent: newMessage });
 
// Subscriber (in subscription resolver)
pubsub.asyncIterableIterator("MESSAGE_SENT");

Architecture and Design Patterns

Event-Driven Architecture

The subscription system follows an event-driven architecture where mutations are event producers and subscriptions are event consumers. This decoupling allows mutations to remain simple while subscriptions handle the complexity of real-time delivery:

// Event producer (mutation)
const resolvers = {
  Mutation: {
    sendMessage: async (_, { input }, { user, db, pubsub }) => {
      const message = await db.message.create({
        data: { ...input, authorId: user.id },
      });
 
      // Publish event - mutation doesn't know or care about subscribers
      pubsub.publish(`MESSAGE_SENT:${input.channelId}`, {
        messageSent: message,
      });
 
      return message;
    },
  },
 
  // Event consumer (subscription)
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator("MESSAGE_SENT"),
        (payload, variables) => {
          return payload.messageSent.channelId === variables.channelId;
        }
      ),
    },
  },
};

Channel-Based Filtering

Filter events at the subscription level to avoid sending irrelevant data to clients. Use topic patterns that encode the filtering criteria:

// Topic pattern: EVENT_TYPE:FILTER_CRITERIA
const TOPICS = {
  MESSAGE_SENT: (channelId) => `MESSAGE_SENT:${channelId}`,
  ORDER_UPDATED: (userId) => `ORDER_UPDATED:${userId}`,
  PRODUCT_CHANGED: (categoryId) => `PRODUCT_CHANGED:${categoryId}`,
};
 
// Publishing
pubsub.publish(TOPICS.MESSAGE_SENT(message.channelId), { messageSent: message });
 
// Subscribing
const resolvers = {
  Subscription: {
    messageSent: {
      subscribe: withFilter(
        (_, variables) => pubsub.asyncIterableIterator(
          TOPICS.MESSAGE_SENT(variables.channelId)
        ),
        () => true // Filtering already done by topic
      ),
    },
  },
};

Multi-Server PubSub

For production deployments with multiple server instances, the built-in PubSub doesn't work because events are local to each process. Use a distributed PubSub backed by Redis, NATS, or Kafka:

import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
 
const pubsub = new RedisPubSub({
  publisher: new Redis({ host: "redis-host", port: 6379 }),
  subscriber: new Redis({ host: "redis-host", port: 6379 }),
});
 
// Now events published on any server instance
// are received by subscriptions on all instances

WebSocket connection management

Step-by-Step Implementation

Setting Up the WebSocket Server

Configure Apollo Server with WebSocket transport using the graphql-ws library:

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 express from "express";
import { createServer } from "http";
import { PubSub } from "graphql-subscriptions";
 
const pubsub = new PubSub();
 
const schema = makeExecutableSchema({
  typeDefs,
  resolvers: createResolvers(pubsub),
});
 
// WebSocket server for subscriptions
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 };
    },
    onSubscribe: async (ctx, msg) => {
      // Log subscription attempts for debugging
      console.log(`Client subscribing: ${msg.payload.query}`);
    },
    onComplete: async (ctx, msg) => {
      // Cleanup when subscription completes
      console.log(`Subscription completed: ${msg.id}`);
    },
  },
  wsServer
);
 
// HTTP server for queries and mutations
const apolloServer = new ApolloServer({
  schema,
  plugins: [
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});
 
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,
  }),
}));
 
httpServer.listen(4000, () => {
  console.log("HTTP server ready at http://localhost:4000/graphql");
  console.log("WebSocket server ready at ws://localhost:4000/graphql");
});

Defining Subscription Schema

Define your subscriptions in the GraphQL schema with proper filtering arguments:

type Subscription {
  # Real-time chat
  messageSent(channelId: ID!): Message!
 
  # Live notifications
  notificationReceived(userId: ID!): Notification!
 
  # Data changes
  orderUpdated(orderId: ID!): Order!
  productStockChanged(productId: ID!): Product!
 
  # Presence
  userStatusChanged(workspaceId: ID!): UserStatus!
}
 
type Message {
  id: ID!
  content: String!
  author: User!
  channel: Channel!
  createdAt: DateTime!
}
 
type UserStatus {
  user: User!
  status: PresenceStatus!
  lastSeen: DateTime
}
 
enum PresenceStatus {
  ONLINE
  AWAY
  OFFLINE
}

Implementing Subscription Resolvers

Build subscription resolvers with proper filtering and authorization:

function createResolvers(pubsub: PubSub) {
  return {
    Subscription: {
      messageSent: {
        subscribe: withFilter(
          () => pubsub.asyncIterableIterator("MESSAGE_SENT"),
          async (payload, variables, context) => {
            // Check if user has access to this channel
            const channel = await context.db.channel.findUnique({
              where: { id: variables.channelId },
              include: { members: true },
            });
 
            return channel.members.some(
              (member) => member.userId === context.user.id
            );
          }
        ),
        resolve: (payload) => payload.messageSent,
      },
 
      notificationReceived: {
        subscribe: withFilter(
          () => pubsub.asyncIterableIterator("NOTIFICATION_RECEIVED"),
          (payload, variables) => {
            return payload.userId === variables.userId;
          }
        ),
      },
 
      orderUpdated: {
        subscribe: withFilter(
          () => pubsub.asyncIterableIterator("ORDER_UPDATED"),
          async (payload, variables, context) => {
            const order = await context.db.order.findUnique({
              where: { id: variables.orderId },
            });
            return order.userId === context.user.id;
          }
        ),
      },
    },
  };
}

Client-Side Subscription with React

Use Apollo Client's useSubscription hook to consume real-time data:

import { useSubscription, useQuery, gql } from "@apollo/client";
import { useState, useEffect } from "react";
 
const MESSAGE_SENT = gql`
  subscription OnMessageSent($channelId: ID!) {
    messageSent(channelId: $channelId) {
      id
      content
      author {
        id
        name
        avatar
      }
      createdAt
    }
  }
`;
 
function ChatRoom({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
 
  // Load initial messages
  const { data: historyData } = useQuery(GET_MESSAGES, {
    variables: { channelId, limit: 50 },
  });
 
  useEffect(() => {
    if (historyData) {
      setMessages(historyData.messages);
    }
  }, [historyData]);
 
  // Subscribe to new messages
  const { data, error } = useSubscription(MESSAGE_SENT, {
    variables: { channelId },
    onData: ({ data }) => {
      setMessages((prev) => [...prev, data.messageSent]);
    },
  });
 
  if (error) return <div>Subscription error: {error.message}</div>;
 
  return (
    <div>
      <MessageList messages={messages} />
      <MessageInput channelId={channelId} />
    </div>
  );
}

Client subscription pattern

Real-World Use Cases

Live Chat Application

A chat application uses subscriptions for message delivery, typing indicators, and presence updates. Each channel has its own subscription topic, and users receive only messages from channels they're members of. Typing indicators use debounced events to avoid flooding the network.

Collaborative Editing

A document editor uses subscriptions to broadcast cursor positions, text selections, and content changes. Each document has a subscription room, and the server applies operational transformation (OT) to resolve concurrent edits before broadcasting the result.

Financial Dashboard

A trading platform subscribes to price updates, order book changes, and trade executions. Subscriptions use channel-based filtering so each widget only receives relevant data. The server batches high-frequency updates to prevent overwhelming clients.

IoT Monitoring

An IoT dashboard subscribes to sensor readings from thousands of devices. Subscriptions filter by device ID and metric type. The server aggregates readings before sending to reduce message frequency while maintaining real-time responsiveness.

Best Practices for Production

  1. Use graphql-ws protocol: The newer graphql-ws protocol is more secure and performant than the deprecated subscriptions-transport-ws. Migrate if you're still using the old protocol.

  2. Authenticate WebSocket connections: Verify auth tokens during the connection_init phase, not on every subscription. Store the authenticated user in the connection context.

  3. Implement connection keepalive: Send periodic ping/pong messages to detect dead connections and prevent proxy timeouts:

const wsServer = new WebSocketServer({
  path: "/graphql",
  // Keepalive every 30 seconds
  heartbeat: { interval: 30000 },
});
  1. Use distributed PubSub in production: The in-memory PubSub doesn't work across multiple server instances. Use Redis, NATS, or Kafka for multi-instance deployments.

  2. Filter events at the server level: Don't send all events to all clients and filter on the client. Use withFilter to filter at the subscription resolver.

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

import { debounce } from "lodash";
 
const debouncedPublish = debounce((topic, payload) => {
  pubsub.publish(topic, payload);
}, 100);
  1. Handle reconnection gracefully: Implement client-side reconnection with exponential backoff and missed event replay:
const client = createClient({
  url: "ws://localhost:4000/graphql",
  retryAttempts: Infinity,
  shouldRetry: () => true,
  on: {
    connected: () => console.log("Connected"),
    closed: () => console.log("Disconnected, retrying..."),
  },
});
  1. Monitor WebSocket connections: Track active connections, message throughput, and error rates. Set alerts for connection count thresholds.

Common Pitfalls and Solutions

PitfallImpactSolution
In-memory PubSub in clustered deploymentsEvents lost between instancesUse Redis or NATS PubSub
Missing authentication on WebSocketUnauthorized data accessVerify tokens in connection_init
Unfiltered subscriptionsClients receive irrelevant eventsUse withFilter or topic-based filtering
Memory leaks from abandoned subscriptionsServer memory growsImplement connection cleanup and timeouts
High-frequency events overwhelming clientsUI lag, battery drainImplement server-side batching/debouncing

Performance Optimization

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

// Batch high-frequency updates
class BatchingPubSub {
  private buffer = new Map<string, any[]>();
  private flushInterval: NodeJS.Timer;
 
  constructor(private pubsub: PubSub, intervalMs = 100) {
    this.flushInterval = setInterval(() => this.flush(), intervalMs);
  }
 
  publish(topic: string, payload: any) {
    if (!this.buffer.has(topic)) {
      this.buffer.set(topic, []);
    }
    this.buffer.get(topic)!.push(payload);
  }
 
  private flush() {
    for (const [topic, payloads] of this.buffer) {
      if (payloads.length > 0) {
        this.pubsub.publish(topic, { batch: payloads });
        this.buffer.set(topic, []);
      }
    }
  }
}

Comparison with Alternatives

FeatureGraphQL SubscriptionsWebSocket (raw)Server-Sent EventsPolling
Type safetyFullNoneNoneFull
Schema integrationYesNoNoYes
BidirectionalYesYesNoNo
ComplexityModerateHighLowLow
ScalabilityGoodGoodGoodPoor
ReconnectionManualManualBuilt-inN/A

Advanced Patterns

Subscription Multiplexing

Reduce WebSocket connections by multiplexing multiple subscriptions over a single connection:

const client = createClient({
  url: "ws://localhost:4000/graphql",
  // All subscriptions share one connection
  connectionParams: { authToken: getToken() },
});
 
// Multiple subscriptions on same connection
function useMultipleSubscriptions(subscriptions) {
  return subscriptions.map((sub) =>
    useSubscription(sub.query, { variables: sub.variables })
  );
}

Subscription-Based Cache Updates

Use subscriptions to keep Apollo Client's cache in sync with server state:

useSubscription(ORDER_UPDATED, {
  variables: { orderId },
  onData: ({ data }) => {
    cache.writeFragment({
      id: `Order:${orderId}`,
      fragment: gql`fragment UpdatedOrder on Order { status total }`,
      data: data.orderUpdated,
    });
  },
});

Testing Strategies

Test subscriptions using mock PubSub and WebSocket test clients:

import { createClient } from "graphql-ws";
import WebSocket from "ws";
 
describe("Message subscription", () => {
  it("receives new messages in real-time", (done) => {
    const client = createClient({
      url: "ws://localhost:4000/graphql",
      webSocketImpl: WebSocket,
    });
 
    client.subscribe(
      {
        query: `subscription { messageSent(channelId: "1") { id content } }`,
      },
      {
        next: (data) => {
          expect(data.data.messageSent.content).toBe("Hello");
          client.dispose();
          done();
        },
        error: done,
        complete: () => {},
      }
    );
 
    // Trigger a message after subscription is active
    setTimeout(async () => {
      await mutate(`mutation { sendMessage(input: { channelId: "1", content: "Hello" }) { id } }`);
    }, 100);
  });
});

Future Outlook

GraphQL subscriptions are evolving with the graphql-ws protocol becoming the standard, replacing the deprecated subscriptions-transport-ws. Future developments include subscription batching (receiving multiple events in a single message), incremental delivery for large subscription payloads, and better integration with edge computing for ultra-low-latency applications.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

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

  1. Use the graphql-ws protocol for modern, secure WebSocket subscriptions
  2. Implement distributed PubSub (Redis, NATS) for multi-server deployments
  3. Filter events at the server level using withFilter or topic-based routing
  4. Authenticate WebSocket connections during connection_init, not per subscription
  5. Implement reconnection, keepalive, and rate limiting for production reliability

Start with a simple PubSub setup for development, then scale to distributed PubSub for production. Combined with proper filtering and batching, GraphQL Subscriptions enable responsive real-time applications that delight users.