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:
completefor an id — that one operation ended normally. Other operations on the socket keep running.errorfor an id — that operation failed; the socket survives and can start more work.- 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 serverping, 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
subscribemessages, sendcompletefor the rest, wait briefly for in-flight writes, then close. Clients that receivecompleteknow 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
Quiz
1. A client sends connection_init but receives "Connection must be initialized". What is the likeliest cause?
2. What happens to the other operations on a socket when the WebSocket itself closes?
3. A subscription works in staging but drops every 60 seconds in production. What should you check first?
Flashcards
Question
subscriptions-transport-ws vs graphql-ws
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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.