JSON vs Binary
You have decided to send one structured object per message. The next fork in the road is how to encode that object on the wire. The WebSocket can carry text or binary, and that single choice — JSON versus a binary format — quietly shapes your debuggability, your bandwidth, and your CPU.
JSON: readable and everywhere
Section titled “JSON: readable and everywhere”JSON.stringify and JSON.parse are built into every browser and every server runtime. JSON is self-describing (the field names travel with the data), human-readable in a network inspector, and understood by every language without a build step. For the overwhelming majority of applications — chat, dashboards, notifications, collaborative editing — JSON is the correct default. You can read a message at a glance, and onboarding a new client means agreeing on field names, nothing more.
Its costs are equally real. Field names are repeated in every message, numbers are stored as decimal text, and there is no native type for raw bytes. A position update of {"x":12,"y":48} spends most of its bytes on the quotes, braces, and the letters x and y rather than the actual values.
Binary: compact and fast
Section titled “Binary: compact and fast”Binary serialization formats trade readability for density:
- MessagePack — “JSON, but binary”. Same data model (objects, arrays, numbers, strings), schema-less like JSON, but encoded compactly. Often the easiest upgrade from JSON.
- Protocol Buffers (Protobuf) — schema-first. You declare your message shape in a
.protofile; field names never travel on the wire (only small numeric tags do), making it the most compact and the fastest to parse, at the cost of a code-generation step and less ad-hoc flexibility. - CBOR — an IETF-standard binary format (RFC 8949) in the MessagePack family, common in IoT and constrained environments.
These shine when messages are small, frequent, and high-volume: multiplayer game state at 60 updates a second, a market data feed, telemetry from thousands of sensors. Shaving bytes and parse time per message adds up fast.
flowchart LR
subgraph json["JSON (text frame)"]
j1["readable · universal · self-describing"]
j2["larger · field names repeated"]
end
subgraph bin["Binary (binary frame)"]
b1["compact · fast to parse"]
b2["needs tooling · not human-readable"]
end
json -- "high volume? tiny messages?" --> bin
bin -- "debuggability? simplicity?" --> json How to choose
Section titled “How to choose”A practical rule of thumb:
- Default to JSON. Reach for it unless you have measured a real problem. Readability and zero tooling are worth a lot, and most apps are nowhere near the bandwidth or CPU limits where encoding matters.
- Switch to binary when the numbers demand it — high message rates, large payloads, mobile clients on metered data, or strict latency budgets. Measure first; a profiler beats a hunch.
- You can mix. Send control/setup messages as JSON for clarity and a hot high-frequency stream as binary. The
typefield discipline from the last lesson works in either encoding.
A JSON demo, and the binary shape in code
Section titled “A JSON demo, and the binary shape in code”The runnable demo below sends a JSON message and reports its size on the wire — feel how many bytes the structure itself costs. It uses the real WebSocket API on an in-page echo socket.
In a real binary setup you would encode the same object with a library before sending it as a binary frame. With MessagePack the shape looks like this — note the binary frame and the arraybuffer receive type:
import { encode, decode } from '@msgpack/msgpack';
// Tell the socket to hand binary messages back as ArrayBuffer, not Blob.const ws = new WebSocket('wss://your-server');ws.binaryType = 'arraybuffer';
ws.onopen = () => { const update = { type: 'pos', x: 12, y: 48 }; const bytes: Uint8Array = encode(update); // compact binary, no repeated field-name text ws.send(bytes); // sent as a BINARY frame, not text};
ws.onmessage = (event: MessageEvent) => { // event.data is an ArrayBuffer for binary frames. const msg = decode(new Uint8Array(event.data)) as { type: string; x: number; y: number }; console.log(msg.type, msg.x, msg.y);};The application logic is identical to the JSON version — only the encode/decode calls and the frame type change. That is exactly why you can start with JSON and migrate the hot paths to binary later if measurements ask for it.