Siddhant Deval
Siddhant Deval
backend17 min read

Subscriptions on the Server: Architecture, PubSub & Transport

A GraphQL subscription is not a WebSocket endpoint — it is an observable field in the schema. The PubSub broker you choose determines your fan-out ceiling, and the transport protocol you choose determines your horizontal scalability ceiling.

Subscriptions on the Server: Architecture, PubSub & Transport

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. Subscriptions extend that resolver contract from one response to an indefinite stream: instead of resolve returning a value, subscribe returns an AsyncIterator and resolve maps each emitted value to the final field output. That two-function contract seems simple until you deploy your GraphQL server to two pods and discover that clients on pod A never receive events published by a client on pod B — because your PubSub is an in-memory EventEmitter that only broadcasts within a single Node.js process.

Architectural Note

This is Part 4 of the GraphQL Backend & API Design series. It assumes you understand Part 1 (Schema Design) and Part 3 (Federation). The federated subscription story — Apollo Router v1.40 native SSE subscriptions — is covered in §5.


1. The Two-Function Resolver Contract

Every GraphQL subscription field requires two separate functions, not one:

TYPESCRIPT
// ❌ Common mistake: treating subscription like a query resolver
const resolvers = {
  Subscription: {
    orderStatusChanged: {
      // Missing subscribe function — this will not work
      resolve: (payload) => payload.orderStatusChanged,
    },
  },
};
TYPESCRIPT
// ✅ Correct: both subscribe and resolve are required
const resolvers = {
  Subscription: {
    orderStatusChanged: {
      // 1. subscribe() — called ONCE when the client opens the subscription.
      //    Must return an AsyncIterator. Each value it yields triggers resolve().
      subscribe: async (_, { orderId }, ctx) => {
        // Returns an AsyncIterator that emits { orderStatusChanged: Order }
        // objects whenever an order's status changes
        return ctx.pubsub.subscribe(`ORDER_STATUS:${orderId}`);
      },

      // 2. resolve() — called for EACH value yielded by the AsyncIterator.
      //    Transforms the raw payload into the subscription field's return type.
      resolve: (payload: { orderStatusChanged: Order }) => {
        return payload.orderStatusChanged;
      },
    },
  },
};

The execution flow:

  1. Client opens WebSocket/SSE connection and sends subscription { orderStatusChanged(orderId: "o1") { status } }
  2. subscribe() runs once — returns AsyncIterator scoped to ORDER_STATUS:o1
  3. When order o1's status changes, the iterator yields a payload
  4. resolve() transforms the payload into the Order type
  5. The resolved value is sent to the client
  6. Repeat from step 3 until the client disconnects

2. PubSub Broker Decision

The PubSub implementation is the most consequential architectural decision for subscriptions. It determines whether your subscription system survives horizontal scaling.

2.1 The In-Memory Anti-Pattern

TYPESCRIPT
// ❌ In-memory PubSub — works in local dev, silently broken in production
import { EventEmitter } from 'events';

class InMemoryPubSub {
  private emitter = new EventEmitter();

  async publish(topic: string, payload: unknown) {
    // Emits only within THIS Node.js process
    // If you have 3 pods, only clients connected to THIS pod receive the event
    this.emitter.emit(topic, payload);
  }

  async subscribe(topic: string): AsyncIterator<unknown> {
    // The listener only fires when publish() is called on this same instance
    return createAsyncIterator(this.emitter, topic);
  }
}

// In a 3-pod deployment:
// Client A connects to Pod 1 (subscribes to ORDER_STATUS:o1 on Pod 1's EventEmitter)
// Client B triggers an order update → request routes to Pod 3
// Pod 3 calls pubsub.publish('ORDER_STATUS:o1') → Pod 3's EventEmitter fires
// Pod 1's EventEmitter NEVER fires → Client A receives NOTHING
Performance / Safety Warning

In-memory PubSub (including PubSub from graphql-subscriptions) is appropriate ONLY for development environments with a single process. Never use it in any deployment with horizontal scaling, sticky sessions, or process restarts. The data loss is silent — no error is thrown, no log is written, subscriptions simply stop delivering events.

2.2 Redis PubSub (Standard Production Choice)

TYPESCRIPT
// ✅ Redis PubSub — events fan out across all pods
import Redis from 'ioredis';
import { RedisPubSub } from 'graphql-redis-subscriptions';

const publisher = new Redis({ host: 'redis', port: 6379 });
const subscriber = new Redis({ host: 'redis', port: 6379 });

// Note: Redis requires separate publisher and subscriber connections.
// A connection in SUBSCRIBE mode cannot issue normal Redis commands.
const pubsub = new RedisPubSub({
  publisher,
  subscriber,
});

// When Pod 3 publishes:
await pubsub.publish('ORDER_STATUS:o1', { orderStatusChanged: updatedOrder });
// Redis broadcasts to ALL subscribers on ALL pods — Pod 1 receives it ✅

2.3 Kafka PubSub (Event-Heavy Workloads)

TYPESCRIPT
// ✅ Kafka PubSub — durable event log, replay, strict ordering
import { Kafka } from 'kafkajs';

const kafka = new Kafka({ brokers: ['kafka:9092'] });
const producer = kafka.producer();
const consumer = kafka.consumer({ groupId: 'graphql-subscriptions' });

// Kafka advantages over Redis for subscriptions:
// - Event replay: subscribers can resume from a committed offset after restart
// - Strict ordering: partition-level ordering guarantees
// - Durability: events survive broker restarts (with replication)
// - Fan-out: same event can be consumed by multiple consumer groups independently
PubSub Broker Fan-out Scale Event Ordering Replay Operational Complexity Cost
In-memory EventEmitter Single process only N/A ❌ None None Free
Redis Pub/Sub ✅ All pods Per-channel FIFO ❌ None Low Low
Redis Streams ✅ All pods Per-stream FIFO ✅ Consumer groups Medium Low
Kafka ✅ All pods + consumer groups Per-partition strict ✅ Offset-based High Medium

3. withFilter — Per-Subscriber Routing

Without filtering, every subscriber on a channel receives every event on that channel. withFilter wraps the subscribe AsyncIterator with a per-message filter function so that each subscriber only receives events relevant to them.

TYPESCRIPT
import { withFilter } from 'graphql-subscriptions';

const resolvers = {
  Subscription: {
    orderStatusChanged: {
      // withFilter wraps the base AsyncIterator
      // The filter function runs for EVERY published event, for EVERY subscriber
      subscribe: withFilter(
        // 1. Base subscription — subscribes to the broad ORDER_STATUS topic
        (_, __, ctx) => ctx.pubsub.subscribe('ORDER_STATUS'),

        // 2. Filter function — called per event per subscriber
        // Return true to deliver to this subscriber, false to skip
        (payload: { orderStatusChanged: Order }, variables: { orderId: string }, ctx) => {
          // Only deliver if the order ID matches what this subscriber requested
          // AND the subscriber has permission to view this order
          return (
            payload.orderStatusChanged.id === variables.orderId &&
            ctx.user.canViewOrder(variables.orderId)
          );
        }
      ),
      resolve: (payload) => payload.orderStatusChanged,
    },
  },
};
Crucial Requirement

withFilter runs synchronously in the subscription delivery path. If your filter function performs a database query or async operation, it will block subscription delivery for all clients on that topic. Keep filter functions pure and synchronous — authorization checks should use data already present in the context, not new DB queries.


4. Transport: graphql-ws vs. graphql-sse

Two production-ready transports exist for GraphQL subscriptions:

4.1 graphql-ws — WebSocket Protocol

TYPESCRIPT
// ✅ graphql-ws v5 — the standard WebSocket subscription protocol
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { createYoga } from 'graphql-yoga';

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

useServer(
  {
    schema,
    context: (ctx) => ({
      user: authenticateWsConnection(ctx.connectionParams),
      pubsub,
    }),
    // v5.16+: lazy initialization — connection is not authenticated
    // until the first subscription operation is sent, reducing auth load
    // for clients that open a connection but haven't sent operations yet
    onConnect: async (ctx) => {
      const token = ctx.connectionParams?.authorization;
      if (!token) return false; // Reject unauthenticated connections immediately
    },
  },
  wsServer
);

4.2 graphql-sse — Server-Sent Events Protocol

TYPESCRIPT
// ✅ graphql-sse — stateless, HTTP/2, horizontally scalable without sticky sessions
import { createHandler } from 'graphql-sse/lib/use/node';
import { createServer } from 'http';
import { schema } from './schema';

const handler = createHandler({
  schema,
  context: (req) => ({
    user: authenticateRequest(req),
    pubsub,
  }),
});

// SSE uses standard HTTP — no WebSocket upgrade required
// Advantage: works through HTTP proxies, load balancers, and CDNs without configuration
// Advantage: automatic reconnection is handled by the browser's EventSource API
// Tradeoff: unidirectional — client cannot send messages after the connection is open
createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/graphql/stream') {
    return handler(req, res);
  }
  // ... handle other routes
});
Transport Protocol Proxy-friendly Bidirectional Reconnection Horizontal Scale
graphql-ws WebSocket Requires config ✅ Yes Manual Requires sticky sessions
graphql-sse HTTP/SSE ✅ Native ❌ One-way ✅ Automatic (EventSource) ✅ Stateless

5. Apollo Router v1.40 — Native Federated Subscriptions

In a Federation architecture, subscriptions require Router-level support. Apollo Router v1.40+ supports native subscription fan-out without a separate subscription gateway:

YAML
# router.yaml — enable subscription support in Apollo Router v1.40+
supergraph:
  listen: 0.0.0.0:4000

subscription:
  enabled: true
  # Router uses SSE to communicate with subgraphs, then re-packages
  # the events in the transport the client requested (WS or SSE)
  mode:
    passthrough:
      all:
        path: /graphql
GRAPHQL
# Subgraph schema — declare the subscription field in the owning subgraph
type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

The Router subscription flow:

  1. Client sends subscription over WebSocket or SSE to Router
  2. Router opens an SSE connection to the Orders subgraph for the subscription field
  3. The subgraph publishes events via Redis/Kafka
  4. Events are received by the subgraph's SSE handler and forwarded to the Router
  5. Router forwards to the connected client
  6. If the subscription spans multiple subgraphs (e.g., Order + User), Router re-executes the non-subscription fields on each event


Flow Trace: subscription event path from Redis through withFilter, AsyncIterator, WebSocket/SSE transport to client
Flow Trace: subscription event path from Redis through withFilter, AsyncIterator, WebSocket/SSE transport to client

An order status change event is published to Redis, propagated to all pods' subscribers, filtered per-subscriber by withFilter, yielded through the AsyncIterator, and delivered via WebSocket or SSE to the connected client.

Comparison Matrix: In-memory EventEmitter vs. Redis PubSub vs. Kafka across fan-out scale, ordering, replay, operational complexity, and cost
Comparison Matrix: In-memory EventEmitter vs. Redis PubSub vs. Kafka across fan-out scale, ordering, replay, operational complexity, and cost

In-memory PubSub is single-process only — events are silently dropped in multi-pod deployments. Redis provides horizontal fan-out with low operational cost. Kafka adds durability, replay, and strict ordering at the cost of higher complexity.


Summary

Concept Rule
Two-function contract Every subscription field requires both subscribe (returns AsyncIterator) and resolve (transforms each yielded payload). Missing subscribe produces no events; missing resolve passes raw payloads to the client.
In-memory PubSub EventEmitter-based PubSub is development-only. In any multi-process deployment, events published on one pod are invisible to subscribers on all other pods — no error is thrown.
Redis PubSub The standard production PubSub: events broadcast to all pods. Requires separate publisher and subscriber Redis connections; a connection in SUBSCRIBE mode cannot issue other commands.
withFilter Runs synchronously per event per subscriber — keep filter functions pure. Async operations inside withFilter block subscription delivery for all clients on the channel.
Transport graphql-sse is stateless, proxy-native, and horizontally scalable without sticky sessions. graphql-ws is bidirectional but requires WebSocket proxy configuration and sticky sessions under load.

What's Next

Part 5 — Observability: Tracing, Metrics & Debugging the Graph covers the operational contract: anonymous operations make every GraphQL server ungovernable. Naming conventions, OpenTelemetry span structure, and field-level usage analytics are the instrumentation layer that makes the schema evolution decisions in Parts 1 and 7 safe to execute.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Subscriptions#WebSockets#SSE#Redis PubSub
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.