Skip to main content

Command Palette

Search for a command to run...

GraphQL Subscriptions: Real-Time WebSockets

Published
9 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Metadata

SEO Title: GraphQL Subscriptions with WebSockets: Real-Time Guide

Meta Description: Learn how to implement GraphQL subscriptions with WebSockets for real-time data streaming. Production-ready patterns, code examples, and best practices.

Primary Keyword: GraphQL subscriptions WebSockets

Secondary Keywords: real-time GraphQL, GraphQL subscription implementation, WebSocket GraphQL server, GraphQL live data, subscription resolver patterns, GraphQL pub-sub architecture, real-time data streaming GraphQL

Tags: GraphQL, WebSockets, Real-Time, TypeScript, API-Design, Backend, System-Architecture

Search Intent: how-to

Content Role: pillar


Article

Modern applications demand instant data updates. When a user posts a comment, places an order, or updates their status, other connected clients expect to see those changes immediately—not after polling or refreshing. Traditional REST APIs force clients into inefficient polling loops or complex long-polling implementations that waste bandwidth and server resources. This creates a poor user experience and increases infrastructure costs as your application scales.

GraphQL subscriptions with WebSockets solve this problem by establishing persistent, bidirectional connections that push data to clients the moment it changes. This approach powers real-time features in collaboration tools, trading platforms, social feeds, IoT dashboards, and multiplayer games. Without proper implementation, however, you'll face connection storms, memory leaks, authentication failures, and scaling bottlenecks that can bring down your entire system.

Why Traditional Approaches Fail for Real-Time Data

REST APIs with polling create unnecessary network traffic and introduce latency. A client polling every second generates 86,400 requests per day per user—most returning empty responses. Server-Sent Events (SSE) provide one-way communication but lack the bidirectional capability needed for complex interactions and don't integrate naturally with GraphQL's type system.

HTTP/2 push was promising but never gained widespread adoption and doesn't provide the connection semantics needed for true real-time subscriptions. Long-polling creates resource exhaustion as connections pile up, and its request-response model doesn't align with GraphQL's declarative data fetching philosophy.

GraphQL subscriptions with WebSockets provide a standardized, type-safe approach that leverages your existing GraphQL schema, resolvers, and authentication infrastructure while maintaining persistent connections optimized for real-time data delivery.

Modern GraphQL Subscription Architecture

A production-grade GraphQL subscription system requires three core components: a WebSocket transport layer, a pub-sub messaging system, and subscription resolvers that connect your data sources to active subscriptions.

Setting Up the WebSocket Server

Modern GraphQL servers use graphql-ws (the WebSocket sub-protocol) rather than the deprecated subscriptions-transport-ws. Here's a production-ready setup using Apollo Server 4 and TypeScript:

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { createServer } from 'http';
import express from 'express';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const typeDefs = `#graphql
  type Message {
    id: ID!
    content: String!
    userId: String!
    timestamp: String!
  }

  type Query {
    messages: [Message!]!
  }

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

  type Subscription {
    messageAdded(channelId: String!): Message!
    messageUpdated(messageId: ID!): Message!
  }
`;

const resolvers = {
  Query: {
    messages: async () => {
      // Fetch from database
      return [];
    },
  },
  Mutation: {
    sendMessage: async (_: any, { content }: { content: string }, context: any) => {
      const message = {
        id: crypto.randomUUID(),
        content,
        userId: context.userId,
        timestamp: new Date().toISOString(),
      };

      // Save to database
      await saveMessage(message);

      // Publish to subscribers
      await pubsub.publish('MESSAGE_ADDED', { 
        messageAdded: message,
        channelId: context.channelId 
      });

      return message;
    },
  },
  Subscription: {
    messageAdded: {
      subscribe: (_: any, { channelId }: { channelId: string }, context: any) => {
        // Verify user has access to channel
        if (!context.userId) {
          throw new Error('Authentication required');
        }

        return pubsub.asyncIterator(['MESSAGE_ADDED']);
      },
      resolve: (payload: any, args: { channelId: string }) => {
        // Filter by channel
        if (payload.channelId === args.channelId) {
          return payload.messageAdded;
        }
        return null;
      },
    },
  },
};

const schema = makeExecutableSchema({ typeDefs, resolvers });

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

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

  const serverCleanup = useServer({
    schema,
    context: async (ctx) => {
      // Extract auth token from connection params
      const token = ctx.connectionParams?.authorization;
      const userId = await validateToken(token);
      return { userId, channelId: ctx.connectionParams?.channelId };
    },
    onConnect: async (ctx) => {
      console.log('Client connected');
    },
    onDisconnect: async (ctx) => {
      console.log('Client disconnected');
    },
  }, wsServer);

  const server = new ApolloServer({
    schema,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use('/graphql', express.json(), expressMiddleware(server, {
    context: async ({ req }) => ({
      userId: await validateToken(req.headers.authorization),
    }),
  }));

  httpServer.listen(4000, () => {
    console.log('Server running on http://localhost:4000/graphql');
  });
}

async function validateToken(token?: string): Promise<string | null> {
  // Implement JWT validation
  return token ? 'user-123' : null;
}

async function saveMessage(message: any): Promise<void> {
  // Database save logic
}

startServer();

Implementing the Client

Modern clients use graphql-ws for WebSocket connections. Here's a React implementation with proper connection management:

import { createClient } from 'graphql-ws';
import { useEffect, useState } from 'react';

const wsClient = createClient({
  url: 'ws://localhost:4000/graphql',
  connectionParams: async () => {
    const token = localStorage.getItem('authToken');
    return {
      authorization: token,
      channelId: 'channel-123',
    };
  },
  retryAttempts: 5,
  shouldRetry: () => true,
  on: {
    connected: () => console.log('WebSocket connected'),
    closed: () => console.log('WebSocket closed'),
  },
});

function MessageFeed({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<any[]>([]);

  useEffect(() => {
    const unsubscribe = wsClient.subscribe(
      {
        query: `
          subscription OnMessageAdded($channelId: String!) {
            messageAdded(channelId: $channelId) {
              id
              content
              userId
              timestamp
            }
          }
        `,
        variables: { channelId },
      },
      {
        next: (data) => {
          if (data.data?.messageAdded) {
            setMessages((prev) => [...prev, data.data.messageAdded]);
          }
        },
        error: (error) => {
          console.error('Subscription error:', error);
        },
        complete: () => {
          console.log('Subscription completed');
        },
      }
    );

    return () => {
      unsubscribe();
    };
  }, [channelId]);

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content}</div>
      ))}
    </div>
  );
}

Scaling with Redis Pub-Sub

The in-memory PubSub implementation doesn't work across multiple server instances. Production systems require a distributed pub-sub mechanism like Redis:

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

const redisOptions = {
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT || '6379'),
  retryStrategy: (times: number) => Math.min(times * 50, 2000),
};

const pubsub = new RedisPubSub({
  publisher: new Redis(redisOptions),
  subscriber: new Redis(redisOptions),
});

// Use the same pubsub instance in resolvers
const resolvers = {
  Mutation: {
    sendMessage: async (_: any, { content }: any, context: any) => {
      const message = {
        id: crypto.randomUUID(),
        content,
        userId: context.userId,
        timestamp: new Date().toISOString(),
      };

      await saveMessage(message);

      // Now publishes across all server instances
      await pubsub.publish(`MESSAGE_ADDED_${context.channelId}`, { 
        messageAdded: message 
      });

      return message;
    },
  },
  Subscription: {
    messageAdded: {
      subscribe: (_: any, { channelId }: any) => {
        return pubsub.asyncIterator(`MESSAGE_ADDED_${channelId}`);
      },
    },
  },
};

Common Pitfalls and Edge Cases

Connection Storms: When your server restarts, thousands of clients reconnect simultaneously. Implement exponential backoff with jitter on the client side and rate limiting on the server.

Memory Leaks: Subscriptions that never unsubscribe accumulate in memory. Always implement cleanup in useEffect return functions and track active subscriptions with monitoring.

Authentication Expiry: WebSocket connections persist longer than JWT tokens. Implement token refresh mechanisms or force reconnection when tokens expire:

const wsClient = createClient({
  url: 'ws://localhost:4000/graphql',
  connectionParams: async () => {
    const token = await refreshTokenIfNeeded();
    return { authorization: token };
  },
  keepAlive: 10000, // Send ping every 10 seconds
});

Over-Subscription: Clients subscribing to too many channels exhaust server resources. Implement per-user subscription limits and use subscription batching where possible.

Thundering Herd: A single database update triggering thousands of subscription notifications. Implement debouncing and batch notifications:

const notificationQueue = new Map<string, NodeJS.Timeout>();

async function publishWithDebounce(channel: string, payload: any, delay = 100) {
  if (notificationQueue.has(channel)) {
    clearTimeout(notificationQueue.get(channel)!);
  }

  notificationQueue.set(channel, setTimeout(async () => {
    await pubsub.publish(channel, payload);
    notificationQueue.delete(channel);
  }, delay));
}

Network Instability: Mobile clients frequently disconnect. Implement message queuing and replay mechanisms so clients can catch up on missed updates after reconnection.

Best Practices for Production

Implement Health Checks: Monitor WebSocket connection counts, subscription counts per user, and pub-sub message rates. Alert when thresholds are exceeded.

Use Connection Pooling: Limit concurrent WebSocket connections per server instance. Use a load balancer with sticky sessions or implement connection draining during deployments.

Secure Your Subscriptions: Validate authorization for every subscription, not just at connection time. Re-check permissions before sending each update:

Subscription: {
  messageAdded: {
    subscribe: withFilter(
      () => pubsub.asyncIterator('MESSAGE_ADDED'),
      async (payload, variables, context) => {
        return await userHasAccessToChannel(context.userId, variables.channelId);
      }
    ),
  },
}

Implement Graceful Degradation: When WebSocket connections fail, fall back to polling. Detect connection issues and switch transport mechanisms automatically.

Monitor and Log: Track subscription lifecycle events, connection durations, and error rates. Use structured logging to correlate subscription issues with specific users or channels.

Optimize Payload Size: Only send changed fields, not entire objects. Use GraphQL's field selection to minimize data transfer.

Set Timeouts: Implement idle connection timeouts and maximum subscription durations to prevent resource exhaustion.

Frequently Asked Questions

How do GraphQL subscriptions differ from queries and mutations? Subscriptions establish long-lived connections that push data to clients when events occur, while queries and mutations use request-response patterns. Subscriptions use WebSocket transport instead of HTTP, enabling real-time bidirectional communication without polling.

Can I use GraphQL subscriptions without WebSockets? Yes, but WebSockets are the standard. Alternatives include Server-Sent Events (SSE) for one-way communication or HTTP multipart responses, but these lack broad tooling support and don't integrate as cleanly with GraphQL clients.

How many concurrent subscriptions can a single server handle? This depends on your server resources and subscription complexity. A typical Node.js server handles 10,000-50,000 concurrent WebSocket connections. Use horizontal scaling with Redis pub-sub to support millions of subscriptions across multiple instances.

What happens when a subscription resolver throws an error? The error is sent to the client through the WebSocket connection, and the subscription terminates. Implement error handling in your resolvers and use try-catch blocks to send graceful error messages without terminating the connection.

How do I test GraphQL subscriptions? Use tools like graphql-ws client in integration tests, or mock the pub-sub system in unit tests. Test connection lifecycle, authentication, authorization, reconnection logic, and error handling separately.

Should I use subscriptions for all real-time features? No. Use subscriptions for truly real-time, event-driven updates. For data that changes infrequently or where slight delays are acceptable, polling or cache invalidation may be simpler and more cost-effective.

How do I handle subscription performance at scale? Implement subscription filtering at the resolver level, use Redis pub-sub for distributed systems, batch notifications, implement rate limiting, and consider using specialized real-time infrastructure like Kafka for high-throughput scenarios.

Conclusion

GraphQL subscriptions with WebSockets provide a powerful, type-safe approach to real-time data delivery that integrates seamlessly with your existing GraphQL infrastructure. By implementing proper authentication, using distributed pub-sub systems like Redis, and following production best practices around connection management and error handling, you can build scalable real-time features that enhance user experience without compromising system stability.

Start by implementing a simple subscription for a single feature, monitor its performance in production, and gradually expand to more complex real-time scenarios. Focus on proper cleanup, authentication, and error handling from the beginning—these foundational elements prevent the scaling issues that plague many real-time systems. Test your reconnection logic thoroughly, implement graceful degradation, and always monitor subscription metrics to catch issues before they impact users.