GraphQL subscriptions enable real-time updates in your applications. But unlike queries and mutations, subscriptions are fundamentally different — they maintain persistent connections and push data from server to client. Let’s explore how they work and how to build them right.

How Subscriptions Work

At a high level, GraphQL subscriptions:

  1. Client sends a subscription operation
  2. Server maintains a persistent connection (usually WebSocket)
  3. When relevant events occur, server pushes updates
  4. Client receives updates and updates UI
subscription OnNewMessage($channelId: ID!) {
  messageAdded(channelId: $channelId) {
    id
    content
    sender {
      name
      avatar
    }
    createdAt
  }
}

Unlike queries that return once, subscriptions return a stream of results.

The Protocol: GraphQL over WebSocket

The most common transport for subscriptions is WebSocket. The graphql-ws protocol (which replaced the older subscriptions-transport-ws) defines how messages flow:

Client                          Server
  |                               |
  |------- connection_init ------>|
  |<------ connection_ack --------|
  |                               |
  |------- subscribe ------------>|
  |       { id, payload }         |
  |                               |
  |<-------- next ----------------|
  |       { id, payload }         |
  |<-------- next ----------------|
  |       { id, payload }         |
  |                               |
  |--------- complete ----------->|
  |       { id }                  |

Server Implementation

Let’s look at a practical implementation using Node.js with graphql-yoga:

import { createServer } from 'http';
import { createYoga, createPubSub } from 'graphql-yoga';

const pubsub = createPubSub<{
  'message:added': [channelId: string, message: Message];
  'user:presence': [userId: string, status: 'online' | 'offline'];
}>();

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Message {
        id: ID!
        content: String!
        sender: User!
        createdAt: DateTime!
      }

      type Subscription {
        messageAdded(channelId: ID!): Message!
        userPresence(userId: ID!): PresenceUpdate!
      }
    `,
    resolvers: {
      Subscription: {
        messageAdded: {
          subscribe: (_, { channelId }) => {
            return pubsub.subscribe('message:added', channelId);
          },
          resolve: (payload) => payload,
        },
        userPresence: {
          subscribe: (_, { userId }) => {
            return pubsub.subscribe('user:presence', userId);
          },
          resolve: ([userId, status]) => ({ userId, status }),
        },
      },
    },
  }),
});

// Somewhere in your app when a message is created:
function onMessageCreated(channelId: string, message: Message) {
  pubsub.publish('message:added', channelId, message);
}

Client Implementation

On the client side, libraries like Apollo Client or urql make subscriptions straightforward:

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

const MESSAGE_SUBSCRIPTION = gql`
  subscription OnNewMessage($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      content
      sender {
        name
        avatar
      }
      createdAt
    }
  }
`;

function ChatMessages({ channelId }) {
  const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
    onSubscriptionData: ({ subscriptionData }) => {
      // Handle new message
      const newMessage = subscriptionData.data.messageAdded;
      // Update local cache, show notification, etc.
    },
  });

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return <Message data={data.messageAdded} />;
}

Production Considerations

1. Connection Management

WebSocket connections are expensive. Implement proper lifecycle handling:

const client = createClient({
  url: 'wss://api.example.com/graphql',
  connectionParams: async () => ({
    authToken: await getAuthToken(),
  }),
  retryAttempts: 5,
  retryWait: async (retries) => {
    // Exponential backoff
    await new Promise(resolve => 
      setTimeout(resolve, Math.min(1000 * 2 ** retries, 30000))
    );
  },
  on: {
    connected: () => console.log('WebSocket connected'),
    closed: () => console.log('WebSocket closed'),
    error: (error) => console.error('WebSocket error', error),
  },
});

2. Authentication

Unlike HTTP requests, WebSocket connections don’t send headers per-message. Handle auth at connection time:

// Server
const yoga = createYoga({
  context: async ({ connectionParams }) => {
    if (!connectionParams?.authToken) {
      throw new Error('Missing auth token');
    }
    
    const user = await validateToken(connectionParams.authToken);
    return { user };
  },
});

// Client
const client = createClient({
  connectionParams: {
    authToken: localStorage.getItem('token'),
  },
});

3. Scaling Horizontally

Your pub/sub needs to work across server instances. Use Redis or similar:

import { createClient } from 'redis';
import { RedisPubSub } from 'graphql-redis-subscriptions';

const redis = createClient({ url: process.env.REDIS_URL });
const pubsub = new RedisPubSub({
  publisher: redis.duplicate(),
  subscriber: redis.duplicate(),
});

// Now publishes work across all server instances
pubsub.publish('message:added', message);

4. Filtering and Permissions

Not all subscribers should receive all events. Implement filtering:

const resolvers = {
  Subscription: {
    messageAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(['MESSAGE_ADDED']),
        (payload, variables, context) => {
          // Check if user has access to this channel
          const hasAccess = context.user.channels.includes(variables.channelId);
          // Check if message was in the requested channel
          const matchesChannel = payload.channelId === variables.channelId;
          
          return hasAccess && matchesChannel;
        }
      ),
    },
  },
};

5. Rate Limiting

Protect against subscription abuse:

const MAX_SUBSCRIPTIONS_PER_USER = 10;
const activeSubscriptions = new Map<string, number>();

function enforceSubscriptionLimit(userId: string) {
  const current = activeSubscriptions.get(userId) || 0;
  if (current >= MAX_SUBSCRIPTIONS_PER_USER) {
    throw new Error('Subscription limit exceeded');
  }
  activeSubscriptions.set(userId, current + 1);
  
  return () => {
    // Cleanup when subscription ends
    activeSubscriptions.set(userId, (activeSubscriptions.get(userId) || 1) - 1);
  };
}

Common Patterns

Optimistic Updates with Subscription Confirmation

function sendMessage(content: string) {
  // 1. Add optimistic message to UI
  const optimisticId = uuid();
  addOptimisticMessage({ id: optimisticId, content, sending: true });
  
  // 2. Send mutation
  await createMessage({ content });
  
  // 3. Subscription will bring real message, replace optimistic
  // (handled by subscription callback)
}

// Subscription handler
onSubscriptionData: ({ subscriptionData }) => {
  const realMessage = subscriptionData.data.messageAdded;
  // Replace optimistic with real message
  replaceOptimisticMessage(realMessage);
}

Presence Systems

// When user connects
pubsub.publish('user:presence', { 
  userId, 
  status: 'online',
  lastSeen: new Date() 
});

// When user disconnects (WebSocket close handler)
server.on('connection', (socket) => {
  socket.on('close', () => {
    pubsub.publish('user:presence', { 
      userId, 
      status: 'offline',
      lastSeen: new Date() 
    });
  });
});

Debugging Tips

  1. Use GraphQL Playground — Most playgrounds support subscriptions for testing
  2. Log subscription lifecycle — Connect, subscribe, unsubscribe, disconnect
  3. Monitor active subscriptions — Track count per topic and per user
  4. Trace message flow — Add correlation IDs to track events through the system

When NOT to Use Subscriptions

Subscriptions aren’t always the answer:

  • Simple polling works fine — If updates are infrequent, polling is simpler
  • One-time data fetches — Use queries, not subscriptions
  • Server-to-server communication — Consider gRPC or message queues
  • Massive fan-out — Millions of subscribers? Consider dedicated infrastructure

Wrapping Up

GraphQL subscriptions are powerful but come with complexity. Key takeaways:

  1. Understand the WebSocket protocol layer
  2. Use Redis or similar for horizontal scaling
  3. Implement proper auth and filtering
  4. Handle connection lifecycle gracefully
  5. Monitor and rate limit

When implemented well, subscriptions create magical real-time experiences for your users. When implemented poorly, they become a scaling nightmare. Choose wisely, and test thoroughly.


Working on GraphQL subscriptions? I’d love to hear about your experiences and challenges.