skip to content

WebSocket Subprotocol Negotiation

Two WebSocket subprotocols with confusingly similar names are chosen in one header, and picking the wrong one fails silently. Interviewers ask because migrating between them breaks live clients.

part ofGraphQLoverview, primer and where to startread it →
on this pageshow

questions

3

Which WebSocket subprotocol identifiers do GraphQL subscription clients offer?

level: juniorimportance: must knowfreq 40%

answer

  1. Two protocols, and the names mislead
  2. One string is current, one legacy
  3. The project name is not the wire string
  4. graphql-ws selects the older protocol
  5. Neither is in the GraphQL specification

basics

~10 s

Two identifiers are in use: graphql-transport-ws, the current one, and graphql-ws, the legacy one carried by the subscriptions-transport-ws protocol. The names are crossed, and neither subprotocol is defined by the GraphQL specification.

solid answer

~40 s

A client opening a WebSocket for GraphQL subscriptions offers a subprotocol identifier, and there are two in circulation. `graphql-transport-ws` is the current one; `graphql-ws` is the legacy identifier used by the older **subscriptions-transport-ws** protocol. The trap is that the names are crossed: the newer *project* is called graphql-ws, but the identifier it puts on the wire is `graphql-transport-ws`, while the string `graphql-ws` selects the legacy protocol. The server answers with exactly one identifier, and that choice is fixed for the life of the connection. Neither subprotocol is part of the GraphQL specification or of the GraphQL over HTTP specification, which covers request/response over HTTP and says nothing about sockets — they are community protocols, and the executable document you send is identical under either one. Only the message wrapper around it differs.

code

pseudocode · 9 lines
pseudocode
// The identifier is chosen when the socket is opened, not per operation.
socket = openWebSocket(
    url          = "wss://lockers.example.com/graphql",
    subprotocols = ["graphql-transport-ws"]   // current protocol
)

// "graphql-transport-ws" -> current protocol
// "graphql-ws"           -> legacy subscriptions-transport-ws protocol
assert socket.negotiatedSubprotocol == "graphql-transport-ws"

go deeper

for a junior

Recall both strings exactly and which protocol each selects: graphql-transport-ws is current, graphql-ws is the legacy subscriptions-transport-ws one. Say the identifier, never just a project name.

for a middle

Explain that the identifier is agreed once per connection and fixed, that only the message wrapper differs while the document is unchanged, and that neither subprotocol is specified by GraphQL.

for a senior

Show you treat the negotiated identifier as an operational fact: recorded per connection, visible in dashboards, and the first thing checked when a subscription connects but delivers nothing.

for a principal

Own the standardisation angle: an unspecified transport means every client, server and intermediary in the estate must agree by convention, so pin the identifier in platform defaults rather than leaving it to per-team choice.

## Why there is a choice at all The GraphQL specification defines `subscription` as an operation type and defines how one is executed, but it says nothing about how the resulting stream reaches a client. The GraphQL over HTTP specification — itself a working draft — covers a request and a response over HTTP and likewise defines no socket protocol. So the wire format for a long-lived subscription connection was filled in by the community, and the community filled it in twice. Both answers are WebSocket **subprotocols**: a named message vocabulary that both ends agree to speak over an already-open socket. The identifier is chosen once, when the connection is established: the client offers one or more identifiers, the server answers with exactly one of them, and that one governs every message on that connection until it closes. There is no renegotiation mid-connection and no per-operation override. ## The two identifiers **`graphql-transport-ws`** is the current identifier. It is what a modern subscription client offers by default and what a modern server registers. **`graphql-ws`** is the legacy identifier, belonging to the older **subscriptions-transport-ws** protocol, which was the de-facto standard for years and is still on the wire in shipped applications, embedded devices and internal tools that nobody has rebuilt. ## The crossed names, which is the whole reason this is an interview question Read those two facts together and the trap appears. The *protocol project* whose common name is **graphql-ws** does **not** use the string `graphql-ws`; it defines and uses `graphql-transport-ws`. The string `graphql-ws` selects the *other*, older protocol — the one whose project name is subscriptions-transport-ws. Each name points at the wrong thing. Every remaining problem on this subject descends from that one crossing: an engineer reads "we use graphql-ws" in a runbook, types `"graphql-ws"` into the client's subprotocol list, and has silently asked for the legacy vocabulary from a server that only speaks the modern one. Say both halves out loud in an interview — the identifier *and* which protocol it selects. "We're on graphql-ws" is exactly the ambiguous sentence that causes the outage, because the listener cannot tell whether you mean the project or the wire string. ## What does not change between them The schema does not change. The executable document does not change. A subscription over a parcel-locker graph is written the same way on either subprotocol: ```graphql subscription CompartmentOpened($lockerId: ID!) { compartmentOpened(lockerId: $lockerId) { compartmentId state openedAt } } ``` What changes is the wrapper carrying that document to the server and carrying results back: the two vocabularies use different message type names for the same lifecycle steps, and they differ in what keep-alive and error signalling look like. A resolver author never notices which subprotocol a subscriber used; a client author and an operator notice immediately. ## Why a mismatch is not loud You might expect asking for an unsupported vocabulary to fail hard. Often it does — a server that supports only the modern identifier and is offered only the legacy one should refuse to establish the connection. But two softer outcomes are common enough that they dominate real incidents. First, a server can complete the connection without selecting any subprotocol at all, leaving a socket that is open, healthy by every network check, and understood by nobody. Second, the two vocabularies happen to agree on the very first message a client sends and on its acknowledgement, so a mismatched pair can connect, acknowledge each other, and only diverge when the first actual operation is sent. Either way, the symptom is a subscription that never delivers — not an error at connect time. ## What a junior is expected to do with this Three habits cover most of it. Write the identifier down explicitly in client configuration rather than relying on a default, because defaults differ. When you are told which protocol a server speaks, confirm the *string*, not the project name. And when a subscription silently produces nothing, check the negotiated identifier on the open connection before you go looking at resolvers, publishers or authorization — it is the cheapest hypothesis on the list and it is right surprisingly often. ## A note on where each name came from The ordering explains the mess. The legacy protocol arrived first and took the plain, obvious wire string `graphql-ws` for itself. When a replacement protocol was written years later, that string was already spoken for by an incompatible vocabulary, so the replacement had to pick a different one and chose `graphql-transport-ws` — while its own project came to be known, colloquially, as graphql-ws. The result is two names that each point at the other's protocol in casual conversation. Nothing about it is subtle once you know it, and nothing about it is guessable if you do not, which is precisely why it is asked.

  • Which specification defines these subprotocols?
    Neither is specified by the GraphQL Foundation. The GraphQL specification defines the subscription operation type and its execution, and the GraphQL over HTTP working draft covers requests and responses over HTTP; neither defines a socket vocabulary. Both subprotocols are community protocols with their own published message documentation, which is why servers and clients have to agree on an identifier explicitly rather than relying on a ratified default.
  • Can a client offer both identifiers and let the server decide?
    Yes. A client may offer a list, and the server answers with exactly one of the identifiers it supports. Listing them in preference order — current first, legacy second — is the conventional way to express which you would rather have, though the selection is ultimately the server's. The client must then behave according to whichever identifier came back, which means shipping both message vocabularies rather than assuming its first choice won.
  • Does the subprotocol choice change the schema or the document a client sends?
    No. The schema, the subscription operation, its variables and the shape of each result are unchanged. Only the wrapper differs: the message type names used to start an operation, deliver each result, report an error and end the stream. That is also why a server can support both at once over one execution engine — the vocabularies are adapters over the same subscription execution.

Two dialects where the newer one's obvious name was already taken by the older one's speakers: asking for the name you remember gets you the language you did not want.

saying these in an interview costs you the question

  • Thinks graphql-ws is the newer identifier
  • Says the GraphQL specification defines the subprotocol
  • Assumes the subprotocol can change mid-connection
  • Believes the subscription document differs between the two
  • Cannot name either identifier string exactly
  • Assumes a mismatch always fails loudly at connect time

context

open as a page

How can one GraphQL subscription endpoint serve both graphql-transport-ws and graphql-ws clients?

level: middleimportance: should knowfreq 33%

basics

~20 s

Register both identifiers on the endpoint and dispatch per connection on whichever one was negotiated. Each identifier gets its own message-vocabulary adapter over one shared subscription execution, so the schema and resolvers are written once.

open as a page

A GraphQL subscription socket opens but no events ever arrive — how do you diagnose it?

level: seniorimportance: should knowfreq 45%

basics

~10 s

Check the negotiated WebSocket subprotocol on the open connection first. An empty or mismatched identifier explains an open-but-silent socket more often than resolvers or authorization do, and it costs one field to rule out.

open as a page