Skip to content
advanced Phase 7 · Subscriptions and Realtime Delivery

GraphQL over WebSocket

The graphql-ws protocol handshake, message types, and connection lifecycle.

55m
1 problems
Topic Progress 0%

Two Protocols Sharing Port 443

Two Protocols Sharing Port 443

The wire format for GraphQL over WebSocket is not part of the GraphQL specification, and there are two of them. subscriptions-transport-ws shipped first and is deprecated; graphql-ws is its maintained replacement. Both handshake against the same endpoint, so a mismatch surfaces as a confusing protocol error rather than a version complaint — the classic symptom being "Connection must be initialized" from a server that just received connection_init.

Purpose subscriptions-transport-ws (deprecated) graphql-ws
Open the session connection_init connection_init
Server accepts connection_ack connection_ack
Start an operation start subscribe
Deliver an event data next
Operation failed error / connection_error error
Operation finished complete complete
Keepalive server-pushed ka ping / pong, either side

Two rows drive real migration decisions. Start versus subscribe: an old client sends start, a new server ignores an unknown message type and the client waits forever for a payload that will never come. And keepalive: the old protocol pushed ka frames from the server only, so a client could not tell a half-open TCP connection from a healthy idle one — graphql-ws makes keepalive bidirectional, which is what lets either side detect a dead peer.

Note also that in graphql-ws, connection_init may carry auth as its payload, and the server may answer with connection_ack or reject the connection outright with error before any operation starts. That single frame is where identity for the whole socket is established.

The npm package is also called graphql-ws, so protocol name, library name, and spec collapse into one string. Every debugging session on this transport starts by confirming which of the two is actually on the wire — with a proxy or a framework in the middle, the answer is not always what the package.json suggests.

Walking the Handshake

Walking the Handshake

client                                   server
  |
  |---- connection_init {token} ----------->| auth payload, sent once
  |<--- connection_ack ---------------------|
  |                                         |
  |---- subscribe id:"1" {query,vars} ----->|  operation 1 starts
  |<--- next id:"1" {data:{...}} -----------|
  |---- subscribe id:"2" {query} ---------->|  operation 2, same socket
  |<--- next id:"1" {data:{...}} -----------|
  |<--- next id:"2" {data:{...}} -----------|
  |<--- ping -------------------------------|  keepalive, either direction
  |---- pong ------------------------------>|
  |                                         |
  |---- complete id:"1" ------------------->|  client unsubscribed
  |<--- error id:"2" {message} -------------|  event source failed
  |---- complete id:"2" ------------------->|  client released the failed op

The id is the whole multiplexing story: one socket carries many independent operations, so a client can hold a chat subscription, a notification subscription, and still run a query over the same connection if the server permits it. Each subscribe message carries an id and a payload with query, variables, and operationName — the same document a POST body would carry.

A minimal client:

import { createClient } from 'graphql-ws';

const client = createClient({
  url: 'wss://api.example.com/graphql',
  connectionParams: { authorization: 'Bearer ' + token },
  keepAlive: 20_000, // sends ping every 20s
  retryAttempts: 5,
});

const stop = client.subscribe(
  { query: SUB_ORDER_SHIPPED, variables: { orderId: '42' } },
  {
    next: (msg) => render(msg.data),
    error: (err) => showToast(err),
    complete: () => console.log('stream ended'),
  },
);

Three behaviors worth knowing before you debug this library. connection_init is sent lazily on the first operation, so a bad token surfaces at subscribe time rather than at connect. retryAttempts reconnects and re-subscribes every active id after a drop, which is why clients recover from deploys without app code. And stop() sends complete for that id only — the socket and every other operation stay alive.

Termination Semantics and Operational Traps

Termination Semantics and Operational Traps

Three different endings look identical to a naive client:

  1. complete for an id — that one operation ended normally. Other operations on the socket keep running.
  2. error for an id — that operation failed; the socket survives and can start more work.
  3. Socket close — everything dies. Closing the socket terminates every active operation the client had open on that connection, and the close frame carries no per-operation detail.

That third rule is what makes deployments and idle proxies dangerous. A load balancer that kills quiet WebSockets after 60 seconds of silence will silently drop a subscription that stopped producing events; the client sees a close and must reconnect and re-subscribe every operation it held. Sending ping more often than the shortest idle timeout in your path — typically every 15-30 seconds — keeps the connection visibly alive.

A checklist for production sockets

  • Keepalive: client pings under the proxy idle timeout; server answers pong. If you only rely on server ping, a client behind NAT that cannot speak first still looks dead.
  • Reconnect: exponential backoff with jitter, then re-subscribe every id you were holding. createClient({ retryAttempts }) does both; doing only the first leaves you connected with zero operations.
  • Graceful shutdown: on SIGTERM, stop accepting new subscribe messages, send complete for the rest, wait briefly for in-flight writes, then close. Clients that receive complete know the server ended things on purpose; clients that only see a TCP reset reconnect immediately, all at once, against a fleet still starting up.
  • Transport choice: mutations and short queries over HTTP stay easier to cache, retry, and trace. Multiplexing everything onto one socket is convenient, but it ties query availability to socket health.

Interview answer that lands: name the three termination signals, state that a socket close is global to all operations on it, and mention the proxy idle timeout as the failure you actually debug in production.

Practice Problems

0 / 1 solved
Author the graphql-ws Message Sequence
Protocol Sequence Design

A client opens one socket, authenticates, starts two subscriptions, keeps the connection alive through a quiet minute, sees operation 1 complete normally and operation 2 fail, then the server drains for a deploy. Write the exact frame sequence in both directions, with ids, and state what the client must do after the final frame.

Example:

Input: One socket, two subscriptions (ids 1 and 2), operation 1 completes, operation 2 errors, server drains

Output: -> connection_init | <- connection_ack | -> subscribe 1 | -> subscribe 2 | <- next 1 | <- ping | -> pong | <- complete 1 | <- error 2 | -> complete 2 | server closes after drain

complete and error are scoped to an id, so the other operation keeps running; the drain ends with a socket close, which terminates everything still open on that connection.

Optimal Solution — O(1) per message time, O(n) for n active operations on the socket space

Authenticate once on the connection, address every operation by id, ping faster than the idle timeout, scope failures to their id, and end the drain with complete messages followed by the close so clients do not all reconnect at once.

// client -> server
{"type":"connection_init","payload":{"authorization":"Bearer <token>"}}
{"type":"subscribe","id":"1","payload":{"query":"subscription { orderShipped(orderId:\"42\") { id status } }","variables":{"orderId":"42"}}}
{"type":"subscribe","id":"2","payload":{"query":"subscription { messageAdded(roomId:\"r1\") { id } }"}}
{"type":"pong"}
{"type":"complete","id":"2"}   // client releases the failed operation

// server -> client
{"type":"connection_ack"}
{"type":"next","id":"1","payload":{"data":{"orderShipped":{"id":"42","status":"SHIPPED"}}}}
{"type":"ping"}
{"type":"complete","id":"1"}   // operation 1 ended normally, socket stays open
{"type":"error","id":"2","payload":[{"message":"event source unavailable"}]}
// drain: server sends complete for anything still open, then closes the socket

Edge Cases:

  • Quiet period longer than the proxy idle timeout
  • Two operations sharing one id
  • connection_init carrying an expired token
  • Socket close while both operations are still active

Quiz

1. A client sends connection_init but receives "Connection must be initialized". What is the likeliest cause?

Question 1 options

2. What happens to the other operations on a socket when the WebSocket itself closes?

Question 2 options

3. A subscription works in staging but drops every 60 seconds in production. What should you check first?

Question 3 options

Flashcards

Question

subscriptions-transport-ws vs graphql-ws

Answer

The first is deprecated; graphql-ws replaces it. Key renames: start becomes subscribe, data becomes next, server-pushed ka becomes bidirectional ping/pong.

Question

graphql-ws message vocabulary

Answer

connection_init / connection_ack open the session; subscribe starts an operation; next delivers an event; error fails one operation; complete ends one operation; ping/pong keep alive.

Question

What an operation id is for

Answer

It multiplexes many independent operations over one socket, so subscribe, next, error, and complete can be routed to the right operation.

Question

Effect of closing the socket

Answer

Every active operation on that connection terminates at once — no per-operation survival, no automatic resume without reconnect and re-subscribe.

Question

Why ping must beat the proxy timeout

Answer

Idle WebSockets get killed by proxies at fixed intervals (often 60 s); pinging more often than the shortest idle timeout keeps the connection alive and detects dead peers.

Revision Notes

Key Takeaways

  • 1. Two protocols share the endpoint; message type names are how you tell them apart on the wire
  • 2. One socket carries many operations, each addressed by id, and a socket close kills all of them
  • 3. Keepalive pings must run faster than the shortest idle timeout anywhere in the request path
  • 4. Graceful shutdown sends complete for active operations so clients do not reconnect in a herd

Interview Tips

  • • Reproduce the message sequence from memory: connection_init, connection_ack, subscribe, next, ping/pong, complete — interviewers often just want the vocabulary right
  • • Explain the difference between an operation ending and a connection ending, and which one a proxy timeout causes
  • • Say why the deprecated protocol still matters: mixed clients in production mean both must be understood during a migration

Cheat Sheet

GraphQL over WebSocket (graphql-ws)

Sequence

-> connection_init {token}
<- connection_ack
-> subscribe id:"1" {query, variables}
<- next id:"1" {data}
<- ping   /   -> pong
-> complete id:"1"

Message types: connection_init, connection_ack, subscribe, next, error, complete, ping, pong.

Migration map from subscriptions-transport-ws: start -> subscribe, data -> next, ka -> ping/pong.

Operational rules

  • One socket, many operations, addressed by id; socket close ends all of them.
  • Ping every 15-30 s, under the proxy idle timeout.
  • Reconnect with backoff, then re-subscribe every held id.
  • On SIGTERM: stop new subscribes, send complete, drain, then close.

Continue Learning