WebSocket Subprotocol Negotiation #

You ship a new message envelope. Old tabs are still open, still connected, and now receiving frames they cannot decode — so half your users see a frozen UI until they reload. Or the opposite failure: you add a subprotocol to the client, forget to echo it on the server, and every connection closes the instant it opens with no error anywhere.

Sec-WebSocket-Protocol is the mechanism RFC 6455 provides for exactly this, and it is both more useful and more unforgiving than most teams expect.

Root cause #

The client may offer a list of subprotocol names in the Sec-WebSocket-Protocol request header. The server must either select exactly one of them and echo it in the 101 response, or omit the header entirely.

The unforgiving part is what happens when the server gets this wrong. If the server echoes a value the client did not offer, the client must fail the connection — per the specification, not as an implementation quirk. The browser closes immediately and reports the same opaque failure it reports for everything else. There is no error message naming the mismatch, which is why this bug typically costs an afternoon.

The useful part is that the negotiation happens before the first byte of application data. That makes it the right place for anything both sides must agree on up front:

Protocol version. v2.chat and v1.chat let a server serve both generations simultaneously, with no version field inside the payload and no feature detection.

Encoding. Offering v2.msgpack and v1.json lets the server choose binary for clients that support it and text for those that do not, as used in binary WebSocket frames with MessagePack.

Capabilities. A client that can handle server-initiated compaction offers v2.compact; one that cannot does not, and the server adjusts.

There is also a widespread hack worth understanding: because the browser WebSocket constructor does not let you set request headers, some systems smuggle an auth token through this header as a second “subprotocol” value. It works, but it has real costs, covered in the FAQ.

What a correct negotiation looks like The client offers an ordered list of subprotocols, the server selects one it also supports and echoes it in the 101 response, and the client reads socket.protocol to decide how to encode; echoing an unoffered value makes the client close immediately. What a correct negotiation looks like Client Server Sec-WebSocket-Protocol: v2.chat, v1.chat select the best mutually supported 101, Sec-WebSocket-Protocol: v2.chat socket.protocol === 'v2.chat' — branch on it wrong: echo 'v3.chat' — client fails the connection Order in the client's list is a preference, not a requirement — the server makes the final choice
The echoed value is a contract: it must be one of the offered names, or the connection dies on arrival.

Resolution #

Offer newest first on the client, select deliberately on the server, and branch on the negotiated value rather than assuming it.

// ── Client ──────────────────────────────────────────────────
// Ordered by preference: the server should pick the first it supports.
const socket = new WebSocket('wss://api.example.com/ws', ['v2.chat', 'v1.chat']);

socket.onopen = () => {
// ALWAYS read what was actually negotiated. An older server may have selected
// v1 even though v2 was offered first, and encoding v2 frames would break it.
switch (socket.protocol) {
case 'v2.chat': encoder = v2Encoder; break;
case 'v1.chat': encoder = v1Encoder; break;
default:
// Empty string means the server ignored the header entirely — an old
// deployment. Decide explicitly: fall back, or refuse to proceed.
encoder = v1Encoder;
}
};
// ── Server (ws) ─────────────────────────────────────────────
import { WebSocketServer } from 'ws';

const SUPPORTED = ['v2.chat', 'v1.chat'] as const;

const wss = new WebSocketServer({
port: 8080,
// Called with the set the client offered. Return the chosen name, or false to
// reject the handshake. Returning anything NOT in the set breaks the client.
handleProtocols: (offered) => {
for (const name of SUPPORTED) { // server preference order wins
if (offered.has(name)) return name;
}
// No overlap. Rejecting here is usually better than connecting with no
// agreed contract and failing on the first message.
return false;
},
});

wss.on('connection', (socket, req) => {
const version = socket.protocol === 'v2.chat' ? 2 : 1;
// Everything downstream reads `version` — never re-parse the header.
socket.send(encodeFor(version, { t: 'welcome' }));
});

Two rules keep this safe during a deploy. First, the server must support the old subprotocol for at least one full client-refresh cycle — for a browser app that is however long your longest-lived tab lives, which is days rather than hours. Second, never remove a subprotocol and add its replacement in the same release; add the new one, wait, then remove the old one in a later deploy.

Rolling out a new subprotocol safely A safe rollout deploys server support for both versions first, then clients that offer the new version, and only removes the old subprotocol once long-lived sessions have drained. Rolling out a new subprotocol safely Serve v1 baseline Serve v1 + v2 server first Clients offer v2 then v1 Serve v2 only after the tail drains deploy server deploy client wait out old tabs Reversing the first two steps means new clients offer something no server understands — and the connection fails at the handshake
Server first, client second, removal last. Every other order breaks somebody's open tab.

Verification #

The failure is silent in the browser, so verify at the wire level.

# What did the server actually select?
curl -i -N --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Protocol: v2.chat, v1.chat" \
https://api.example.com/ws | grep -i sec-websocket-protocol

# What happens when the client offers something unknown? (should be a clean reject)
curl -i -N --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Protocol: v9.future" \
https://api.example.com/ws

In the browser, socket.protocol after onopen is the authoritative value — log it during a rollout and you will see the mix shift from v1 to v2 as clients refresh, which is also the signal that tells you when it is safe to remove the old one. Instrument it server-side too: a counter labelled by negotiated protocol turns “can we drop v1 yet?” from a guess into a graph.

Also check the proxy. Some reverse proxies and CDNs strip unknown request headers, and a stripped Sec-WebSocket-Protocol produces a server that sees no offer and selects nothing — after which a client expecting v2 silently talks v1.

Server response versus client outcome A table of what the client experiences for each combination of offered subprotocols and server response, showing that echoing an unoffered value always fails the connection. Server response versus client outcome Server echoes Client result Offered v2, v1 v2 connects, protocol = v2 Offered v2, v1 v1 connects, protocol = v1 Offered v2, v1 nothing connects, protocol = '' Offered v2, v1 v3 (not offered) fails immediately Offered nothing v2 fails immediately Offered v9 only reject handshake clean failure, visible status Rows four and five are the two ways to produce an unexplained instant close — both are server bugs
Only an echo drawn from the offered list connects. Echoing anything else is indistinguishable from a network failure.

Operational checklist #

FAQ #

Can I use the subprotocol header to pass an auth token? #

You can, and many systems do, because the browser WebSocket constructor cannot set arbitrary headers. The pattern is new WebSocket(url, ['v1.chat', 'bearer.' + token]), with the server reading the token and echoing only the real subprotocol. It works, but it puts the credential in a header that proxies log, it caps token length in practice, and it abuses a field with different semantics. A short-lived ticket in the query string, exchanged immediately, is usually cleaner — see validating JWT on the WebSocket upgrade.

What happens if the server selects nothing? #

The connection succeeds and socket.protocol is the empty string. That is legal and it means “no subprotocol agreed” — so your client must decide what to do rather than assuming its first choice was accepted. Treating an empty value as v1 is a reasonable default during a migration; treating it as v2 is how you ship a silent incompatibility.

Is the client’s order a preference or a requirement? #

A preference. The specification lets the server select any one of the offered values, so a server may legitimately prefer v1 even when v2 is listed first — for instance to shed load from a more expensive codec. That is exactly why the client must branch on what came back rather than on what it asked for.

Can I negotiate more than one thing at once? #

Only one value is selected, so encode compound choices into a single name — v2.msgpack rather than separate v2 and msgpack entries — or offer the full cross-product and let the server pick. Extensions such as permessage-deflate are negotiated through a different header, Sec-WebSocket-Extensions, and both can be used on the same handshake.

Does Socket.IO use subprotocols? #

Not for its own versioning; Engine.IO carries version and transport information in the query string and its own packet format instead. If you are on Socket.IO, the equivalent lever is the path and the client/server version pairing, which is stricter — a major-version mismatch refuses to connect rather than negotiating down.

Back to Protocol Handshake Mechanics