Siddhant Deval
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.

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:
PatternLatencyServer CostWhen to Use
PollingInterval-dependentLow (stateless)Infrequent updates, eventual consistency acceptable, simple infrastructure
@deferSub-second (streaming)Low (single response)Slow fields in an otherwise-fast query — not ongoing updates
SubscriptionsSub-100msHigh (stateful connection)Ongoing server-push: chat, live counters, collaborative presence
typescript
// ❌ Using a subscription for data that changes once an hour
useSubscription(INVENTORY_UPDATE_SUBSCRIPTION)
// Better: poll every 5 minutes
useQuery(GET_INVENTORY, { pollInterval: 5 * 60 * 1000 })

// ✅ Subscriptions for real-time collaborative presence
useSubscription(USER_CURSOR_SUBSCRIPTION, {
  variables: { documentId },
})
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:
Client → Server: GQL_CONNECTION_INIT (optionally with auth payload)
Server → Client: GQL_CONNECTION_ACK
Client → Server: GQL_SUBSCRIBE { id, payload: { query, variables } }
Server → Client: GQL_NEXT { id, payload: { data } }   (repeated for each event)
Server → Client: GQL_COMPLETE { id }                   (when subscription ends)

2.2 Reconnection with Exponential Backoff

Without reconnection configuration, a single network blip permanently drops the subscription channel:
typescript
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'

const wsClient = createClient({
  url: 'wss://api.example.com/graphql',

  // ✅ Reconnection with exponential backoff — mandatory for production
  retryAttempts: Infinity,        // Keep retrying forever
  retryWait: async (retries) => {
    // Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
    const delay = Math.min(1000 * 2 ** retries, 30_000)
    await new Promise(resolve => setTimeout(resolve, delay))
  },

  // ✅ Lazy mode — connect only when first subscription is registered
  lazy: true,
})

const wsLink = new GraphQLWsLink(wsClient)
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
import { Client, createClient } from 'graphql-sse'

const sseClient: Client = createClient({
  url: 'https://api.example.com/graphql/stream',
  headers: () => ({
    // ✅ SSE uses standard Authorization headers — works with all proxies
    Authorization: `Bearer ${getAccessToken()}`,
  }),
  retryAttempts: Infinity,
})

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.
WebSocketSSE
DirectionBidirectionalServer → Client only
Horizontal scalingRequires sticky sessionsStateless — no sticky sessions
Firewall compatibilityEnterprise firewalls often block WSStandard HTTP — never blocked
Authconnection_params payloadStandard Authorization header
Browser supportUniversalUniversal (IE 11 excluded)

Apollo Client routes queries and mutations to HttpLink and subscriptions to the WebSocket or SSE link using split():
typescript
import { ApolloClient, InMemoryCache, HttpLink, split, from } from '@apollo/client'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { getMainDefinition } from '@apollo/client/utilities'
import { createClient } from 'graphql-ws'
import { authLink, retryLink, errorLink } from './links'

const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' })

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'wss://api.example.com/graphql',
    lazy: true,
    retryAttempts: Infinity,
  })
)

// ✅ Route by operation type
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    )
  },
  wsLink,   // Subscriptions → WebSocket
  httpLink, // Queries + Mutations → HTTP
)

const client = new ApolloClient({
  link: from([authLink, retryLink, errorLink, splitLink]),
  cache: new InMemoryCache(),
})
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
// ❌ This header does NOT reach the WebSocket server
const authLink = setContext(async (_, { headers }) => ({
  headers: { ...headers, Authorization: `Bearer ${token}` },
}))
// HTTP headers are sent on the HTTP upgrade request — most WebSocket servers ignore them
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
const wsClient = createClient({
  url: 'wss://api.example.com/graphql',

  // ✅ Auth via connection_params — standard graphql-ws pattern
  connectionParams: async () => {
    const token = await getAccessToken()
    return { Authorization: `Bearer ${token}` }
  },
})
The server validates the token in the onConnect handler:
typescript
// Server-side (graphql-ws handler)
const server = useServer(
  {
    schema,
    onConnect: async (ctx) => {
      const token = ctx.connectionParams?.Authorization
      if (!token || !validateToken(token)) {
        throw new Error('Unauthorized')
      }
    },
  },
  wsServer,
)
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
// ❌ Parallel state channel — disconnected from the cache replica
function ChatRoom({ roomId }: { roomId: string }) {
  const { data } = useQuery(GET_MESSAGES, { variables: { roomId } })
  const [liveMessages, setLiveMessages] = useState<Message[]>([])

  useSubscription(MESSAGE_ADDED, {
    variables: { roomId },
    onData: ({ data }) => {
      setLiveMessages(prev => [...prev, data.data.messageAdded])
    },
  })

  // data.messages and liveMessages are now two separate, unmerged lists
  // Sorting, deduplication, and consistency must be managed manually
}
The correct pattern is subscribeToMore, which merges subscription events directly into the existing query's cache entry:
typescript
// ✅ subscribeToMore — merges events into the replica
function ChatRoom({ roomId }: { roomId: string }) {
  const { data, subscribeToMore } = useQuery(GET_MESSAGES, {
    variables: { roomId },
  })

  useEffect(() => {
    const unsubscribe = subscribeToMore({
      document: MESSAGE_ADDED_SUBSCRIPTION,
      variables: { roomId },
      updateQuery: (prev, { subscriptionData }) => {
        const newMessage = subscriptionData.data.messageAdded
        return {
          messages: [...prev.messages, newMessage],
          // The cache normalizes newMessage into Message:${newMessage.id}
          // All other components reading that message will update automatically
        }
      },
    })
    return unsubscribe  // ← Clean up on unmount
  }, [roomId, subscribeToMore])

  return <MessageList messages={data?.messages} />
}
WebSocket subscription lifecycle — client subscribes, server pushes events, subscribeToMore updateQuery merges each event into the normalized cache replica, React re-renders
WebSocket subscription lifecycle — client subscribes, server pushes events, subscribeToMore updateQuery merges each event into the normalized cache replica,…
WebSocket vs SSE vs Polling comparison across latency, horizontal scalability, firewall compatibility, authentication complexity, and server statelessness
WebSocket vs SSE vs Polling comparison across latency, horizontal scalability, firewall compatibility, authentication complexity, and server statelessness
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

ConceptRule
Protocolsubscriptions-transport-ws was archived in 2022 — all new implementations must use the graphql-ws protocol; the two are not compatible on the server.
SSE scalabilitySSE outperforms WebSockets for unidirectional server-to-client push at scale: stateless servers, no sticky sessions, HTTP/2 multiplexing, firewall-friendly.
WebSocket authenticationWebSocket 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 patternsubscribeToMore 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.
ReconnectionUse 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
Siddhant Deval

Written by Siddhant Deval

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