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
- Node 22 and
npm i ws@8in a directory outside the project, sorequireworks. - Port 8871 and port 8875 free. Check with
netstat -ano | grep 8871. - curl 8.21.0. The Git Bash curl 8.1.2 has no
wsprotocol, and for this check curl speaks plain HTTP/1.1 with the upgrade headers set by hand. - RFC 6455 section 1.9 for what a subprotocol is, and MDN on the protocols argument.
- The server, saved as
ws-subprotocol.jsand started withnode ws-subprotocol.js:
// 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');
- The client, saved as
ws-client.mjs:
// 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
- 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.jsonThe response names one value, never a list.
v2.jsonis the agreement;v1.jsonwas offered and not chosen. - 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.jsonws.protocolis empty until the socket opens, so read it inonopenand not next to the constructor. - 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-Protocolline, socket open. RFC 6455 permits this: the server agreed to none of the offers and kept the connection. - 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.logerror : 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.
- 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 receivedThe 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
What to check next
- Websocket handshake failed: the rest of the 101, when the failure is not about subprotocols.
- How to test websocket with curl: the same handshake read with the curl build that speaks
wsnatively. - Websocket compression: the other header negotiated in the same response, with the same all-or-nothing shape.
- Websocket close code 1006: what the client reports after it refuses a handshake.
- How to test websocket connection: the baseline check to run before this one.
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.
Related on this site
intermediate6 minpublished updated Maks Verny