Websocket binary frame
Read byte 0 of the frame: 0x81 is a text frame, 0x82 a binary one. The same bytes arrive as a String, a Buffer, an ArrayBuffer or a Blob depending on the client and its binaryType. Check which, because JSON.parse fails on a Blob and succeeds on a Buffer.
Why check this
Run this when a socket carries anything that is not plain JSON strings, and after any change to how the server sends. The failure it prevents: a server switched from ws.send(json) to ws.send(Buffer.from(json)) during a refactor. The Node integration suite keeps passing, because the ws client hands both to JSON.parse as a Buffer and JSON.parse accepts it. The browser gets a Blob for the same bytes and throws on the first message.
The frame type is a protocol fact, one bit pattern in the first byte. Everything above it is a client decision, and the two Node clients on this machine make opposite ones. A check that reads only the client API cannot tell you which of the two you are looking at.
Prerequisites
- Node 22 and
npm i ws@8in a directory outside the project, sorequireworks. - Port 8874 free. Check with
netstat -ano | grep 8874. - curl 8.21.0 and
odfor the byte view. The Git Bash curl 8.1.2 has nowsprotocol. - RFC 6455 section 5.6 for the two data opcodes, and MDN on binaryType.
- The server, saved as
ws-binary.jsand started withnode ws-binary.js > ws-binary.log. It sends the same 22 characters twice, once as a string and once as a Buffer:
// ws-binary.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8874 });
const body = '{"id":1,"status":"ok"}';
wss.on('connection', (ws) => {
ws.send(body); // string -> text frame, opcode 1
ws.send(Buffer.from(body)); // Buffer -> binary frame, opcode 2
ws.on('message', (data, isBinary) => {
console.log('received', isBinary ? 'binary' : 'text', data.length, 'bytes:', [...data].join(','));
});
});
console.log('listening on 8874');
- Three clients.
ws-binary-wslib.js, using the ws client:
const WebSocket = require('ws');
const ws = new WebSocket('ws://127.0.0.1:8874/');
console.log('binaryType :', ws.binaryType);
let n = 0;
ws.on('message', (data, isBinary) => {
console.log(`frame ${++n}: isBinary ${isBinary}, constructor ${data.constructor.name}`);
try { console.log(' JSON.parse ->', JSON.stringify(JSON.parse(data))); }
catch (err) { console.log(' JSON.parse ->', err.constructor.name + ': ' + err.message); }
if (n === 2) ws.close();
});
ws-binary-client.mjs, using the WebSocket built into Node 22:
const ws = new WebSocket('ws://127.0.0.1:8874/');
if (process.argv[2]) ws.binaryType = process.argv[2];
console.log('binaryType :', ws.binaryType);
let n = 0;
ws.onmessage = (e) => {
const d = e.data;
console.log(`frame ${++n}: typeof ${typeof d}, constructor ${d.constructor.name}`);
try { console.log(' JSON.parse ->', JSON.stringify(JSON.parse(d))); }
catch (err) { console.log(' JSON.parse ->', err.constructor.name + ': ' + err.message); }
if (n === 2) ws.close();
};
And ws-binary-send.mjs, which sends in both directions:
const ws = new WebSocket('ws://127.0.0.1:8874/');
ws.onopen = () => {
ws.send('1,2,3'); // string -> text frame
ws.send(new Uint8Array([1, 2, 3])); // typed array -> binary frame
setTimeout(() => ws.close(), 300);
};
Steps
- Step 1.
Open the socket with curl and dump the bytes the server sends, before any client interprets them.
curl -s --max-time 2 --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' http://127.0.0.1:8874/ | od -An -tx1 -v81 16 7b 22 69 64 22 3a 31 2c 22 73 74 61 74 75 73 22 3a 22 6f 6b 22 7d 82 16 7b 22 69 64 22 3a 31 2c 22 73 74 61 74 75 73 22 3a 22 6f 6b 22 7dTwo frames, one after the other.
81is FIN plus opcode 1, text.82is FIN plus opcode 2, binary.16is 22, the payload length, and the 22 bytes that follow are identical in both. - Step 2.
Read the same two frames with the ws client.
node ws-binary-wslib.jsbinaryType : nodebuffer frame 1: isBinary false, constructor Buffer JSON.parse -> {"id":1,"status":"ok"} frame 2: isBinary true, constructor Buffer JSON.parse -> {"id":1,"status":"ok"}Both frames arrive as a Buffer and both parse. The opcode survives only in the
isBinaryargument, which is the second parameter of the handler and easy to leave out. - Step 3.
Read them again with the WebSocket built into Node 22, under both binary types.
for t in blob arraybuffer; do node ws-binary-client.mjs $t; donebinaryType : blob frame 1: typeof string, constructor String JSON.parse -> {"id":1,"status":"ok"} frame 2: typeof object, constructor Blob JSON.parse -> SyntaxError: Unexpected token 'o', "[object Blob]" is not valid JSON binaryType : arraybuffer frame 1: typeof string, constructor String JSON.parse -> {"id":1,"status":"ok"} frame 2: typeof object, constructor ArrayBuffer JSON.parse -> SyntaxError: Unexpected token 'o', "[object ArrayBuffer]" is not valid JSONSame server, same bytes, four different types across two clients. The error names the type, so
[object Blob]in a parse error is the symptom to search for. - Step 4.
Run the same read in Chrome, from a page at
http://127.0.0.1:8874/so the origin matches.const ws = new WebSocket('ws://127.0.0.1:8874/'); console.log('binaryType :', ws.binaryType); let n = 0; ws.onmessage = (e) => { console.log(`frame ${++n}: constructor ${e.data.constructor.name}`); try { console.log(' JSON.parse ->', JSON.stringify(JSON.parse(e.data))); } catch (err) { console.log(' JSON.parse ->', err.constructor.name + ': ' + err.message); } if (n === 2) ws.close(); };binaryType : blob frame 1: constructor String JSON.parse -> {"id":1,"status":"ok"} frame 2: constructor Blob JSON.parse -> SyntaxError: Unexpected token 'o', "[object Blob]" is not valid JSONThose four lines are the Console output. Chrome 152 and Node 22 agree on the default and on the error text, and the ws client is the one that differs.
- Step 5.
Send in the other direction and read what the server received.
node ws-binary-send.mjs; sleep 1; tail -2 ws-binary.logreceived text 5 bytes: 49,44,50,44,51 received binary 3 bytes: 1,2,3The string
1,2,3is five characters,31 2c 32 2c 33. TheUint8Arrayis three bytes whose values are 1, 2 and 3. The frame type decides which of the two the server gets.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Byte 0 is 0x81 | Text frame, opcode 1, payload is valid UTF-8 | Nothing. Every client hands this to you as a string or a Buffer. |
| Byte 0 is 0x82 | Binary frame, opcode 2, payload is opaque bytes | Decide the client type before parsing. |
| SyntaxError: ... "[object Blob]" is not valid JSON | A binary frame reached code written for text | Fix the sender, or decode with await data.text() before parsing. |
| Handler receives a Buffer for both frames | You are on the ws client, which hides the opcode | Read the isBinary argument, and test the browser path separately. |
| binaryType is blob | The default in Chrome 152 and in Node 22 | Set arraybuffer if you index bytes, since a Blob needs an await first. |
| The server logs more bytes than you sent values | Numbers went out as characters, not as bytes | Send a Uint8Array, not a joined string. |
Common mistakes
What to check next
- Websocket compression: the other flag in byte 0, read the same way.
- How to test websocket message size limit: binary payloads are where a size limit is usually hit first.
- How to check websocket message order: text and binary frames share one ordered stream.
- How to check websocket connection in chrome: where the browser reading in step 4 comes from.
- How to test websocket connection: the baseline check to run before this one.
FAQ
What is the difference between a text frame and a binary frame?
One bit pattern in the first byte: opcode 1 against opcode 2. A text frame promises valid UTF-8 and a receiver may reject it if it is not. A binary frame promises nothing about the bytes. The payload can be identical, as step 1 shows.
Why does JSON.parse fail on a WebSocket message?
Because the message arrived as a Blob or an ArrayBuffer and JSON.parse calls toString on it first. The parse error quotes [object Blob], which is the string it was given, not the data. Decode the message before parsing.
What should binaryType be set to?
arraybuffer when you read bytes in the same tick, since a Blob needs await data.text() or arrayBuffer() first. Leave it at blob when you hand the payload straight to a file save or an object URL.
Does a binary frame use fewer bytes than text?
For numeric data, yes. Three small integers are 3 bytes as a Uint8Array and 5 bytes as the string 1,2,3, and the gap widens with larger numbers. For text there is nothing to gain, and a text frame is easier to read in DevTools.
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.
Everything above came from a local server on port 8874 on one Windows machine. The client types are properties of these versions: nodebuffer is a ws default, blob is what Chrome 152 and Node 22.23.2 use. The Chrome lines are one capture from one headless run.
Related on this site
intermediate7 minpublished updated Maks Verny