Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 29, 2026·13 min read
Subscriptions on the Client: WebSockets, SSE & the Live Replica
GraphQL subscriptions are not a push notification system — they are a stateful channel that keeps a specific slice of the client replica synchronized with live server state. Choosing the wrong transport protocol guarantees operational failure at scale.
Technical Series
GraphQL Frontend Engineering
Part 5 of 8
Subscriptions on the Client: WebSockets, SSE & the Live Replica
The production incident report reads: "Real-time updates stopped for all users at 14:32. Resolved at 16:45 by restarting the subscription server." No root cause. No fix. Just a restart that bought two hours of stability before the same failure repeated.
This is the failure mode of a subscription setup chosen by copying a tutorial. The tutorial used
subscriptions-transport-ws (archived in 2022). The server had no sticky-session configuration despite running behind a load balancer. The client had no reconnection logic, so every WebSocket drop was permanent until a page refresh. None of these were considered failure cases — they were just default behaviors that no one examined.A GraphQL client is not a data fetcher — it is a local replica of your server's data graph. Subscriptions keep that replica live: a persistent channel that pushes server-state changes directly into the normalized cache, triggering re-renders wherever that data is consumed. The transport choice (WebSocket vs. SSE) determines whether that channel survives at scale.
1. When Subscriptions Are the Right Tool
Three real-time patterns compete in frontend engineering. Choose based on update frequency, latency requirements, and the cost of server-side statefulness:
| Pattern | Latency | Server Cost | When to Use |
|---|---|---|---|
| Polling | Interval-dependent | Low (stateless) | Infrequent updates, eventual consistency acceptable, simple infrastructure |
@defer | Sub-second (streaming) | Low (single response) | Slow fields in an otherwise-fast query — not ongoing updates |
| Subscriptions | Sub-100ms | High (stateful connection) | Ongoing server-push: chat, live counters, collaborative presence |
typescript
Crucial Requirement
Every WebSocket connection holds a persistent TCP connection open on the server. At 10,000 concurrent users, that is 10,000 open file descriptors — each server process has a per-process limit (typically 65,536). Use subscriptions only when sub-second latency is genuinely required.
2. The graphql-ws Protocol
subscriptions-transport-ws was archived in August 2022. It is incompatible with the graphql-ws server implementation at the protocol level — you cannot mix them. All new subscription setups must use graphql-ws.2.1 Protocol Handshake
The
graphql-ws protocol defines a message-based handshake over a standard WebSocket connection:2.2 Reconnection with Exponential Backoff
Without reconnection configuration, a single network blip permanently drops the subscription channel:
typescript
Performance / Safety Warning
retryAttempts: Infinity retries indefinitely. This is correct for user-facing subscriptions where reconnection is transparent. For operations with side effects on reconnect (e.g., rejoining a room), add logic in the on('connected') callback to re-initialize server state after each reconnect.3. SSE Transport via graphql-sse
Server-Sent Events (SSE) is an HTTP/2-based unidirectional push protocol. For GraphQL subscriptions that only need server-to-client data flow (which is most of them), SSE is architecturally superior to WebSockets at scale.
3.1 Why SSE Outperforms WebSocket at Scale
WebSocket connections are stateful: each open connection is tied to a specific server process. Behind a load balancer, all messages for a given connection must reach the same server (sticky sessions). Without sticky sessions, a request for an event update reaches the wrong server and returns nothing.
SSE connections are HTTP/2 streams. HTTP/2 servers are inherently stateless at the protocol level — each request is independent. No sticky sessions required. Horizontal scaling is trivial.
typescript
3.2 When WebSocket Is Still Correct
SSE is unidirectional: server pushes to client. If your subscription protocol requires bidirectional messaging (rare in GraphQL subscriptions —
graphql-ws connection init is the only client-to-server message needed), WebSocket remains the correct choice.| WebSocket | SSE | |
|---|---|---|
| Direction | Bidirectional | Server → Client only |
| Horizontal scaling | Requires sticky sessions | Stateless — no sticky sessions |
| Firewall compatibility | Enterprise firewalls often block WS | Standard HTTP — never blocked |
| Auth | connection_params payload | Standard Authorization header |
| Browser support | Universal | Universal (IE 11 excluded) |
4. Apollo Client Split-Link Setup
Apollo Client routes queries and mutations to
HttpLink and subscriptions to the WebSocket or SSE link using split():typescript
The
from([...links, splitLink]) chain means auth, retry, and error links run for all operations — including subscriptions. This is correct: subscriptions need error interception and the auth link for their connection init payload.5. WebSocket Authentication
This is the most common subscription security bug: expecting the HTTP
Authorization header to apply to WebSocket connections.typescript
The WebSocket upgrade request (
HTTP GET with Upgrade: websocket) can carry custom headers in theory, but browser WebSocket APIs do not support custom headers. The standard pattern for WebSocket auth in GraphQL is the connection init payload:typescript
The server validates the token in the
onConnect handler:typescript
Performance / Safety Warning
Never put authentication tokens in the WebSocket URL (
wss://api.example.com/graphql?token=...). URLs are logged by every proxy, load balancer, and server access log between the client and the server. A token in the URL is a token in plaintext in every log file in your infrastructure.6. subscribeToMore: The Correct Replica-Update Pattern
The most common incorrect subscription pattern stores events in
useState:typescript
The correct pattern is
subscribeToMore, which merges subscription events directly into the existing query's cache entry:typescript

Expand

Expand
Mental Model Check
subscribeToMore is to subscriptions what cache.modify is to mutations — both are surgical writes to the replica. The difference is the source: mutations write from a server response, subscribeToMore writes from a push event. The replica update model is identical.Summary
| Concept | Rule |
|---|---|
| Protocol | subscriptions-transport-ws was archived in 2022 — all new implementations must use the graphql-ws protocol; the two are not compatible on the server. |
| SSE scalability | SSE outperforms WebSockets for unidirectional server-to-client push at scale: stateless servers, no sticky sessions, HTTP/2 multiplexing, firewall-friendly. |
| WebSocket authentication | WebSocket connections authenticate once at connection time via the connection_params payload — Authorization headers are ignored on the WebSocket upgrade request in most servers. |
| Replica update pattern | subscribeToMore is the correct replica-update pattern — it merges subscription events into an existing query's cache entry rather than creating a parallel, disconnected state channel. |
| Reconnection | Use retryAttempts + retryWait (exponential backoff) in the graphql-ws client config — without it, a single network blip silently drops the subscription channel forever. |
What's Next
In Part 6, we tackle the most invisible bug class in GraphQL frontend development: HTTP 200 responses that are actually partial failures. The
errors array, the extensions.code field, and fragment-level Error Boundaries form the complete error handling architecture for production GraphQL clients.Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.
#GraphQL#Subscriptions#WebSocket#SSE#graphql-ws#Apollo Client#Real-time
Technical Series
GraphQL Frontend Engineering
Part 5 of 8