Websocket subprotocol

Send the handshake with Sec-WebSocket-Protocol: v2.json, v1.json and read the 101 response. The server names one of your offers in the same header, or omits the header, which is a legal answer. Node and Chrome both fail the connection when the header is missing, so read ws.protocol after open.

Why check this

Run this when a client and a server ship on different schedules and the message format is versioned by subprotocol. The failure it prevents: a client updated to offer only v2.json against a server that still knows v1.json. On the wire the handshake succeeds. The server logs a new connection and keeps the socket. The browser reports a failed handshake and the tester goes looking for a proxy that is not there.

The check answers two questions. Which name the server agreed to, read from its own response rather than from a configuration file. And what each client library does when no name comes back, because the protocol allows the server to decline and the clients here do not.

Prerequisites

// ws-subprotocol.js
const { WebSocketServer } = require('ws');

// 8871: agrees to v2.json when it is offered, declines otherwise.
const good = new WebSocketServer({
  port: 8871,
  handleProtocols: (offered) => (offered.has('v2.json') ? 'v2.json' : false),
});
good.on('connection', (ws, req) => {
  console.log('8871 offered:', req.headers['sec-websocket-protocol'] || '(none)');
  console.log('8871 agreed :', ws.protocol === '' ? '(none)' : ws.protocol);
  ws.send(JSON.stringify({ agreed: ws.protocol }));
});

// 8875: answers with a subprotocol whatever the client offered.
const bad = new WebSocketServer({ port: 8875, handleProtocols: () => 'v9.json' });
bad.on('connection', (ws) => console.log('8875 agreed :', ws.protocol));

console.log('listening on 8871 and 8875');
// ws-client.mjs <comma-separated subprotocols>
const codecs = { 'v2.json': { decode: JSON.parse }, 'v1.json': { decode: JSON.parse } };
const ws = new WebSocket('ws://127.0.0.1:8871/', process.argv[2].split(','));
ws.onopen = () => {
  console.log('readyState :', ws.readyState);
  console.log('ws.protocol:', JSON.stringify(ws.protocol));
};
ws.onmessage = (e) => {
  console.log('decoded    :', codecs[ws.protocol].decode(e.data).agreed);
  ws.close();
};
ws.onerror = (e) => console.log('error      :', e.message);

Steps

  1. Step 1.

    Offer two subprotocols in a handshake written by hand and read the response headers.

    curl -s --max-time 2 -o /dev/null -D - --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Protocol: v2.json, v1.json' http://127.0.0.1:8871/
    
    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Sec-WebSocket-Protocol: v2.json

    The response names one value, never a list. v2.json is the agreement; v1.json was offered and not chosen.

  2. Step 2.

    Read the agreed name from a client, which is where application code has to take it from.

    node ws-client.mjs 'v2.json,v1.json'
    
    readyState : 1
    ws.protocol: "v2.json"
    decoded    : v2.json

    ws.protocol is empty until the socket opens, so read it in onopen and not next to the constructor.

  3. Step 3.

    Offer a name the server does not know and read the response headers again.

    curl -s --max-time 2 -o /dev/null -D - --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Protocol: v3.json' http://127.0.0.1:8871/
    
    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

    Status 101, no Sec-WebSocket-Protocol line, socket open. RFC 6455 permits this: the server agreed to none of the offers and kept the connection.

  4. Step 4.

    Send the same offer from a real client and compare it with what the server recorded.

    node ws-client.mjs 'v3.json'; tail -2 ws-subprotocol.log
    
    error      : Server did not respond with sent protocols.
    8871 offered: v3.json
    8871 agreed : (none)

    The two halves of one handshake disagree. The server counted a connection. The client discarded it.

  5. Step 5.

    Repeat the offer from Chrome, at http://127.0.0.1:8871/ so the origin matches, and read the Console.

    new WebSocket('ws://127.0.0.1:8871/', ['v3.json']);
    
    WebSocket connection to 'ws://127.0.0.1:8871/' failed: Error during WebSocket handshake: Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received

    The Console shows that as an error. Chrome names the cause in one line, where Node reports the same refusal as Server did not respond with sent protocols.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Sec-WebSocket-Protocol: v2.json in the 101 | The server agreed to one of your offers | Nothing. Read the same value from ws.protocol. | | 101 with no such header, client offered none | No agreement, and no client expected one | Fine. Your message format is fixed on both sides. | | 101 with no such header, client offered some | Legal on the wire, refused by every client tested here | Add the name to the server's list, or drop it from the client's offer. | | A name you never offered | The client fails the connection by specification | Fix the server. It is answering from a fixed list rather than from the request. | | ws.protocol empty after open | Nothing was agreed | Do not index a codec table with it. Step to the default path instead. |

Common mistakes

Sign: The server log shows a successful connection and the front end reports a failed handshake.Cause: The server declined every offered subprotocol and answered 101 without the header, which RFC 6455 allows. Chrome, Node 22 and the ws client all fail the connection at that point, so the socket the server is holding has no client on it.
Sign: Connection fails with Protocol was not set in the opening handshake.Cause: The server answered with a subprotocol the client never offered, here v9.json against an offer of v1.json. The message points at an absent value, and the actual fault is a value that is present and wrong.
Sign: TypeError: Cannot read properties of undefined (reading 'decode').Cause: Client code looks a codec up by ws.protocol, and ws.protocol is the empty string because the client offered nothing. The socket is open and healthy, so the failure surfaces on the first message rather than at connect time.
Sign: The client offers a list and the server picks the last entry.Cause: Order is a client preference, and the server is free to ignore it. ws with no handleProtocols hook takes the first name in the request. Check which one your server takes rather than assuming your order wins.

What to check next

FAQ

Is a WebSocket subprotocol required?

No. A socket with no agreed subprotocol is a normal socket, and both sides then have to know the message format some other way. The header only matters when a client offers one, because every client tested here treats a missing answer as a failed handshake.

What does an empty ws.protocol mean?

Either the client offered nothing, or the server agreed to nothing and the client tolerated it. The first case reaches your message handler with an open socket. Read it in onopen and branch on it before using it as a key.

Can a client offer more than one subprotocol?

Yes. Pass an array to the constructor and the browser sends a comma separated list. The server answers with one name or with no header. There is no way to agree on two.

Does the subprotocol change the frames on the wire?

No. It names the format of the payload and nothing else. Opcodes, masking, fragmentation and the close handshake are identical whichever name is agreed.

Why did curl show 101 when the browser said the handshake failed?

curl stops at the HTTP response and reports what arrived. The browser applies the WebSocket client rules on top, and one of them rejects a response that carries no subprotocol after a non-empty offer. Both readings of the same bytes are correct.

Verified

Verified by Maks Vernynode 22.23.2ws 8.21.3curl 8.21.0Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

Every figure here came from a local server on ports 8871 and 8875 on one Windows machine. A local socket has no network in it, so nothing above is a latency or a capacity result. The Chrome line is one capture from one headless run of Chrome 152.

intermediate6 minpublished updated Maks Verny