Binary WebSocket Frames with MessagePack #

Twelve people editing one document, thirty cursor updates a second each, and every update is a JSON object whose key names outweigh its values. The wire carries 21 KB/s of mostly-repeated field names, the main thread spends a measurable slice of every frame budget in JSON.parse, and on a mid-range Android phone the cursor trail visibly lags. Binary framing fixes the byte count. Whether it fixes the lag depends on details this page is about.

Root cause #

A WebSocket text frame must contain valid UTF-8, and JSON spells everything out: {"type":"cursor","userId":"u_8827","x":412,"y":88} spends 22 of its 49 bytes on key names that never change and 3 bytes rendering the number 412 as text.

MessagePack encodes the same structure with length-tagged types. A three-entry map header is one byte, a one-character key is two, and a small positive integer is one. There is no schema and no code generation — it is a drop-in replacement for JSON.stringify and JSON.parse that produces Uint8Array instead of string.

The catch is on the receiving side. JSON.parse is implemented in C++ inside the JavaScript engine and is extremely fast; a MessagePack decoder is JavaScript, and on a mid-range mobile device it commonly runs two to four times slower per byte. For small messages that reverses the win — you save eleven bytes of bandwidth and spend more main-thread time than the saving was worth. The size at which binary starts winning is a property of your payloads and your target devices, not a universal constant, which is why the implementation below thresholds rather than switching wholesale.

Bytes on the wire per update, by transport Wire bytes per update for payloads of 16, 49, 256, 1024 bytes: a WebSocket frame adds two to four bytes, an SSE event eight, and a polling round trip repeats 420 bytes of headers. Bytes on the wire per update, by transport frame/protocol overhead only — polling assumes 420 B of HTTP headers WebSocket frame SSE event HTTP poll 0 B 500 B 1.0k B 1.5k B 16 B 49 B 256 B 1024 B
Wire cost by payload size computed from the framing rules. The frame overhead is tiny either way — the saving comes from the payload encoding, which is why it scales with how repetitive your objects are.

Resolution #

Ship both codecs, negotiate at the handshake, and let payload size decide per message. That combination makes the migration safe: old clients keep working, and no single message class can regress.

import { encode, decode } from '@msgpack/msgpack';

const BINARY_THRESHOLD_BYTES = 256; // below this, native JSON.parse wins

// ── Client ──────────────────────────────────────────────────
const socket = new WebSocket('wss://api.example.com/ws', ['v2.msgpack', 'v1.json']);

// MUST be set before any message arrives. The default is 'blob', which forces an
// async read before you can decode — and awaiting it lets messages interleave.
socket.binaryType = 'arraybuffer';

socket.onopen = () => {
// The server echoes the subprotocol it selected; branch on it, do not assume.
console.info('negotiated', socket.protocol);
};

socket.onmessage = (ev) => {
// Never sniff the payload: a valid MessagePack map can begin with a byte that
// looks like printable ASCII. Use the type the browser gives you.
const msg = typeof ev.data === 'string'
? JSON.parse(ev.data)
: decode(new Uint8Array(ev.data as ArrayBuffer));
handle(msg);
};

export function send(payload: unknown): void {
if (socket.readyState !== WebSocket.OPEN) return;
if (socket.protocol === 'v2.msgpack') {
const packed = encode(payload);
// Small messages still go as text: the decode cost on the far side exceeds
// the bytes saved, and text frames are readable in DevTools.
if (packed.byteLength >= BINARY_THRESHOLD_BYTES) return socket.send(packed);
}
socket.send(JSON.stringify(payload));
}
// ── Server (ws) ─────────────────────────────────────────────
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({
port: 8080,
// Pick the best protocol both sides understand; returning false rejects.
handleProtocols: (protocols) =>
protocols.has('v2.msgpack') ? 'v2.msgpack' : protocols.has('v1.json') ? 'v1.json' : false,
});

wss.on('connection', (socket) => {
const binaryOk = socket.protocol === 'v2.msgpack';

socket.on('message', (raw, isBinary) => {
// `isBinary` comes from the frame opcode — authoritative, unlike inspecting bytes.
const msg = isBinary ? decode(raw as Buffer) : JSON.parse(raw.toString('utf8'));
route(socket, msg, binaryOk);
});
});

The binaryType line is the one that causes the most confusion when omitted. With the default 'blob', ev.data is a Blob and decoding requires await blob.arrayBuffer() — which introduces an await inside the message handler and therefore an ordering hazard, exactly the bug described in handling out-of-order WebSocket messages.

Never base64-encode binary into a text frame. It costs 33% more bytes than the binary you were compressing, plus an encode and a decode on both sides, and it is the single most common accidental self-inflicted wound in real-time apps.

Negotiating the codec at the handshake The client offers both codecs in the subprotocol header, the server selects one and echoes it in the 101 response, and afterwards each side chooses text or binary frames per message based on size. Negotiating the codec at the handshake Client Server Upgrade, Sec-WebSocket-Protocol: v2.msgpack, v1.json handleProtocols picks the best shared 101, Sec-WebSocket-Protocol: v2.msgpack binary frame (opcode 0x2) text frame for a 40 B message An old client offering only v1.json is served text throughout — no version flag, no feature detection
Subprotocol negotiation makes the migration free: both codecs coexist until the last old client is gone.

Verification #

Confirm the negotiation, the frame types, and — most importantly — that decode time actually improved.

# Which subprotocol did the server select?
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Protocol: v2.msgpack, v1.json" http://localhost:8080/ws | grep -i protocol

In DevTools, the Messages tab of the WebSocket connection shows binary frames as a byte preview with their length — compare that length against what the same message cost as JSON. Then open the Performance panel and record thirty seconds of live traffic: the JSON version shows a repeating sawtooth of JSON.parse blocks, and the MessagePack version should show a smaller total in decode, not merely a different function name. If it does not, your messages are below the threshold where binary pays, and the right decision is to stay on JSON.

What you gain and what you give up A comparison of JSON, MessagePack and Protobuf across encoded size, decode speed, debuggability, build requirements and schema enforcement. What you gain and what you give up JSON MessagePack Protobuf Bytes (49 B object) 49 31 18 Decode speed native, fastest 2-4x slower generated, fast Readable in DevTools yes no no Build step none none codegen Schema enforced no no yes Good fit small, chatty large, repetitive cross-language MessagePack is the only one of the three that changes nothing about your build or your object model
MessagePack trades debuggability for bytes with no build-step cost — Protobuf trades far more for a little more.

Operational checklist #

FAQ #

Will MessagePack break my existing clients? #

Not if you negotiate. A client that offers only v1.json receives text frames exactly as before, and the server never sends it a binary frame. That is the whole reason to do this at the subprotocol layer rather than with a version field inside the payload — the decision is made before the first message, so there is no window in which a client receives something it cannot decode.

How much smaller is it really? #

For objects with many short keys and small integers, 30–40% is typical, and it improves with repetition because map headers amortise over batched updates. For payloads that are mostly long UTF-8 strings — chat messages, document text — the saving is close to zero, because MessagePack stores the same bytes with a shorter length prefix and nothing else. Measure your actual traffic before committing; a two-hour analysis of real payloads will tell you more than any benchmark.

Should I use permessage-deflate instead? #

They solve different problems and compose badly. Compression is CPU work in exchange for bytes and costs roughly 300 KB of zlib state per socket; binary encoding is a smaller representation at a modest decode cost with no per-socket memory. For large repetitive payloads compression usually wins outright; for many small ones binary wins. Enabling both means compressing already-compact data, which is mostly waste — see permessage-deflate compression trade-offs.

How do I debug binary frames? #

You largely cannot, in DevTools — that is the real cost. Compensate with tooling: a development-mode flag that forces text frames, structured logging of decoded envelopes on both sides, and a small decoder page you can paste a hex dump into. Teams that skip this end up reverting to JSON not because of performance but because an incident took three hours instead of twenty minutes.

Does this work with Socket.IO? #

Socket.IO has its own binary support: it detects binary attachments in your payload and splits them into separate frames, using its own packet encoding rather than MessagePack. There is an official MessagePack parser you can plug in as the encoder, which is the closest equivalent. On raw ws you own the encoding entirely, which is why the threshold logic above is possible at all.

Back to WebSocket Message Framing & Serialization