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.
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.
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.
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.
Related #
- Message Framing & Serialization — the envelope design and the frame anatomy this sits inside.
- permessage-deflate Compression Trade-offs — the other lever for reducing bytes, and when it conflicts with this one.
- WebSocket Subprotocol Negotiation — the handshake mechanism the codec choice rides on.
- Handling Out-of-Order WebSocket Messages — why an async blob read in the handler is a correctness risk.
- Validating WebSocket Messages with Zod — validating the decoded value regardless of wire format.