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.
GraphQL Backend & API Design
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.
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:
The execution flow:
- Client opens WebSocket/SSE connection and sends
subscription { orderStatusChanged(orderId: "o1") { status } } subscribe()runs once — returnsAsyncIteratorscoped toORDER_STATUS:o1- When order
o1's status changes, the iterator yields a payload resolve()transforms the payload into theOrdertype- The resolved value is sent to the client
- 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
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)
2.3 Kafka PubSub (Event-Heavy Workloads)
| 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.
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
4.2 graphql-sse — Server-Sent Events Protocol
| 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:
The Router subscription flow:
- Client sends subscription over WebSocket or SSE to Router
- Router opens an SSE connection to the Orders subgraph for the subscription field
- The subgraph publishes events via Redis/Kafka
- Events are received by the subgraph's SSE handler and forwarded to the Router
- Router forwards to the connected client
- If the subscription spans multiple subgraphs (e.g.,
Order + User), Router re-executes the non-subscription fields on each event

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.

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.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.