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

// 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');
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

  1. 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 -v
    
     81 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 7d

    Two frames, one after the other. 81 is FIN plus opcode 1, text. 82 is FIN plus opcode 2, binary. 16 is 22, the payload length, and the 22 bytes that follow are identical in both.

  2. Step 2.

    Read the same two frames with the ws client.

    node ws-binary-wslib.js
    
    binaryType : 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 isBinary argument, which is the second parameter of the handler and easy to leave out.

  3. 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; done
    
    binaryType : 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 JSON

    Same 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.

  4. 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 JSON

    Those 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.

  5. Step 5.

    Send in the other direction and read what the server received.

    node ws-binary-send.mjs; sleep 1; tail -2 ws-binary.log
    
    received text 5 bytes: 49,44,50,44,51
    received binary 3 bytes: 1,2,3

    The string 1,2,3 is five characters, 31 2c 32 2c 33. The Uint8Array is 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

Sign: JSON.parse throws on a message that looks like correct JSON in the server log.Cause: The server sent it as a binary frame, so the client produced a Blob and JSON.parse stringified that to [object Blob]. The payload was never touched. Only the opcode changed.
Sign: The Node test suite passes and the browser breaks on the same build.Cause: The ws client hands both frame types over as a Buffer, and JSON.parse accepts a Buffer through its string conversion. Node 22's own WebSocket and Chrome both return a Blob, which does not convert. The same code path has two outcomes.
Sign: Byte values arrive multiplied or offset at the server.Cause: A string of digits was sent instead of bytes. Sending '1,2,3' puts five character codes on the wire, 49 44 50 44 51. Sending new Uint8Array([1,2,3]) puts three bytes on the wire.
Sign: data.length is right in Node and undefined in the browser.Cause: A Blob has size, not length, and an ArrayBuffer has byteLength. Only the ws client gives you a Buffer, where length exists. Check the type before measuring a message.

What to check next

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.

intermediate7 minpublished updated Maks Verny