Choosing Between JSON, MessagePack and Protobuf in Workers

The format debate usually starts in the wrong place. Inside a page, postMessage does its own serialisation with structured clone, so a format chosen for the thread boundary is generally a format applied on top of one that is already there.

Where format does matter is the network edge — data arriving from a server, decoded in a worker, handed to the page. This guide is the format half of Data Parsing & Serialization, part of High-Performance Computation Patterns, and it is about where the milliseconds actually go.


Where the Cost Lands

A 40 MB response of 200,000 records, decoded in a worker and handed to the page, has four cost centres — and the format only controls one of them:

  1. Network transfer, governed by compressed size. Gzip and Brotli flatten most of the difference between text formats, so JSON’s verbosity costs far less over the wire than uncompressed sizes suggest.
  2. Decode, governed by the format and its implementation. JSON.parse is native C++; MessagePack and Protobuf decoders in the browser are JavaScript, which starts them at a disadvantage that their simpler grammar has to overcome.
  3. Allocation, governed by the decoded shape. 200,000 objects with 12 fields each is 2.4 million property slots, whatever produced them.
  4. The thread hop, governed by clone versus transfer. Objects clone; typed arrays transfer.

Most format comparisons measure only (2) and then generalise. In an application, (3) and (4) are usually larger, and both are controlled by the decoded shape rather than by the wire format — which is why “decode to columnar typed arrays” beats every format choice made in isolation.

Where the time goes for a 40 MB payload, by stage A stacked breakdown of one 40 megabyte payload handled three ways. With JSON decoded to row objects, decode takes about 420 milliseconds, allocation and garbage collection about 260 milliseconds, and the structured clone to the page about 380 milliseconds, for roughly one second total. With MessagePack decoded to row objects, decode takes about 350 milliseconds and the other two stages are unchanged, so the total barely moves. With any format decoded straight into columnar typed arrays, decode takes about 300 milliseconds, allocation is negligible, and the transfer is effectively free, for a total near 310 milliseconds. The format is one of three costs, and not the biggest JSON → objects ≈ 1.06 s MessagePack → objects ≈ 0.99 s any → columnar ≈ 0.31 s — transfer, not clone 0 0.5 s 1 s decode allocation + collection hop to the page Changing the format moved 70 ms. Changing the decoded shape moved 750 ms.
The second bar is the format debate in full. The third bar is the decision that actually changes the user's experience.

The Three Formats, Honestly

JSON decodes in native code, which is a large structural advantage in a browser: JSON.parse on 40 MB runs at roughly 90–110 MB/s on a 2023 laptop, and no JavaScript decoder matches that per byte. Its costs are wire size before compression, no binary type (base64 inflates by a third), and no schema, so field names are repeated on every record.

MessagePack is JSON’s data model in a binary encoding: no schema, self-describing, typically 20–40% smaller than JSON uncompressed and much closer after gzip. Decoders are JavaScript, so per-byte speed is usually 60–90% of JSON.parse — but it carries binary blobs natively, which matters when the payload contains buffers that would otherwise be base64.

Protobuf requires a schema and a build step, and pays for that with the smallest payloads and the strongest typing. Field names never appear on the wire, integers are varint-encoded, and generated decoders can write straight into typed arrays. For large, numeric, stable-schema payloads it is the fastest of the three; for a small heterogeneous payload it is a build system in exchange for a few kilobytes.

JSON MessagePack Protobuf
Decoder native JavaScript generated JavaScript
Schema none none required
Wire size (uncompressed) baseline −20 to −40% −40 to −70%
After gzip baseline −5 to −15% −20 to −40%
Binary data base64, +33% native native
Runtime cost 0 kB 10–20 kB 30–80 kB
Best at everything ordinary binary blobs, no build step large numeric payloads

What Usually Beats All Three

If the data is fundamentally numeric — time series, telemetry, geometry, embeddings — the fastest path skips record decoding entirely. Send a small JSON header describing the columns, followed by raw little-endian bytes, and build typed-array views directly over them:

// worker.ts — header + raw columns, no per-record decoding at all
const response = await fetch(url);
const buffer = await response.arrayBuffer();

const headerLength = new DataView(buffer).getUint32(0, true);
const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, headerLength))) as {
  rows: number; columns: { name: string; type: 'f64' | 'i32'; offset: number }[];
};

// Views, not copies: this loop does no work proportional to the data size.
const columns = Object.fromEntries(header.columns.map((c) => [
  c.name,
  c.type === 'f64'
    ? new Float64Array(buffer, c.offset, header.rows)
    : new Int32Array(buffer, c.offset, header.rows),
]));

self.postMessage({ kind: 'READY', columns }, [buffer]);   // one transfer, zero copies

Decode time here is the time to parse a few hundred bytes of header. The 40 MB is never copied, never allocated as objects, and moves to the page in a single O(1) transfer. That is why the arrow-shaped formats — Apache Arrow being the standardised version of this idea — dominate benchmarks for analytical data: they are not faster decoders, they are formats that need no decoding.

The constraint is that it only works for regular, columnar data. A tree of heterogeneous objects has no such representation, and for that JSON is both the simplest and usually the fastest option available.


Choosing

Start with JSON. It is native, zero-dependency, debuggable in DevTools, and fast enough for the overwhelming majority of payloads. Change only with a measurement in hand.

Move to MessagePack when payloads carry binary data that base64 is inflating, or when you want smaller uncompressed payloads without adopting a schema toolchain.

Move to Protobuf when it is already the server’s format, or when payloads are large, numeric and schema-stable enough that generated decoders can write into typed arrays.

Skip all three when the data is columnar and numeric — a header plus raw bytes, transferred once, is a different order of magnitude and simpler than any of them.

Whatever the format, decode inside the worker and hand the page a shape it can use without further conversion. A decoder that produces 200,000 objects has moved the cost, not removed it, and the page pays it again on arrival.


Measuring It on Your Own Data

Published benchmarks are run on payload shapes chosen by whoever wrote the decoder. The comparison that matters uses your payload, and it takes about twenty minutes:

// bench.worker.ts — measure all four stages, not just decode
async function measure(url: string) {
  const t0 = performance.now();
  const buffer = await (await fetch(url)).arrayBuffer();
  const t1 = performance.now();                       // network + decompress

  const value = decode(buffer);                       // the format under test
  const t2 = performance.now();                       // decode

  // Force the allocations a real consumer would touch, so the collector's cost is counted.
  let checksum = 0;
  for (const row of value.rows) checksum += row.amount;
  const t3 = performance.now();                       // materialisation

  self.postMessage({ kind: 'RESULT', value, checksum });
  const t4 = performance.now();                       // hop (approximate; clone is synchronous)

  return { network: t1 - t0, decode: t2 - t1, materialise: t3 - t2, hop: t4 - t3 };
}

Run each format five times, take the median, and record all four numbers. Two things usually emerge. The stage that dominates is rarely decode, so the format is rarely the lever. And the variance between runs is dominated by garbage collection, which is a direct function of how many objects the decoded shape creates — so the numbers themselves point at the shape change rather than the format change.

Run it on a mid-tier device too. Native JSON.parse degrades roughly with clock speed; JavaScript decoders degrade faster, which can invert a ranking measured only on a laptop.


Wire size of the same 200,000-record payload, before and after compression Four encodings measured twice. JSON is 40 megabytes uncompressed and about 6 megabytes gzipped. MessagePack is 27 megabytes uncompressed and about 5.4 megabytes gzipped. Protobuf is 14 megabytes uncompressed and about 4.2 megabytes gzipped. A header plus raw little-endian columns is 12.8 megabytes uncompressed and about 11 megabytes gzipped, because packed floats barely compress — but it needs no decoding at all. Bytes on the wire for the same 200,000 records JSON 40 MB · ≈6 MB gzipped MessagePack 27 MB · ≈5.4 MB gzipped Protobuf 14 MB · ≈4.2 MB gzipped header + raw columns 12.8 MB · ≈11 MB gzipped 0 14 28 40 MB Compression flattens most of the difference — which is why decode and allocation decide the outcome.
The bottom row compresses worst and arrives fastest, because its cost is not in the bytes.

Gotchas

Encoding before postMessage. Serialising to JSON and posting the string means a JSON encode, a clone of the string, and a JSON parse — three passes where structured clone alone would have been one. Post the object.

Benchmarking decode alone. A benchmark that measures decode and ignores allocation and the thread hop will recommend a format change worth 7% while the shape change worth 70% goes unexamined.

Assuming smaller is faster. Protobuf’s wire savings are real, but a payload that gzips to a similar size arrives at a similar time; the win is decode and allocation, not bytes.

Comparing formats at the wrong payload size. A format’s constant costs dominate small payloads and disappear on large ones, so a benchmark on 50 kB can rank the three differently from one on 50 MB. Measure at the size you actually ship.

Losing precision in JSON. Integers above 2^53 lose precision silently, and NaN and Infinity are not representable. Binary formats carry both — a real reason to move for financial or scientific data, independent of speed.

Ignoring the debugging cost. JSON is readable in the Network panel; a binary payload is not, and neither is a custom byte layout. Budget for a decoder you can run in DevTools, or a development-mode flag that serves JSON, before adopting a binary format for anything you will have to debug in production.

Schema drift with Protobuf. Field numbers are the contract, not names. Reusing a retired field number produces silently misinterpreted data, which is the same class of failure as Designing Versioned Message Protocols addresses inside the page.


A decision order for wire formats in a browser application Four decision steps in order. Start with JSON because it is native, dependency-free and debuggable. Move to MessagePack only when binary payloads are being inflated by base64 or uncompressed size matters. Move to Protobuf when it is already the server format or payloads are large, numeric and schema-stable. And skip all three when the data is columnar, in favour of a header plus raw bytes that needs no decoding. The order to consider formats in start with JSON native decode, no dependency, readable in DevTools move to MessagePack for binary payloads avoids base64 inflation without adopting a schema toolchain move to Protobuf for large numeric payloads worth a build step when the schema is stable and the server agrees skip all three for columnar data a header plus raw bytes decodes in microseconds and transfers in one hop Each step down the list costs more tooling; only the last one changes the order of magnitude.
Most applications should stop at the first row, and the ones that should not usually belong at the last.

Performance Note

On a 2023 laptop, the same 200,000-record payload: JSON.parse decoded 40 MB in 420 ms; a MessagePack decoder handled the 27 MB equivalent in 350 ms; a generated Protobuf decoder handled 14 MB in 240 ms. The header-plus-raw-bytes layout produced the same columns in 4 ms, because nothing was decoded — and its transfer to the page cost 0.04 ms against 380 ms to clone the object graph the other three produced.

Frequently Asked Questions

Should I serialise data before sending it between threads?
Usually not. postMessage already serialises with structured clone, so encoding to JSON first means paying twice — once for your encoder, once for the clone of the resulting string. Serialisation formats matter for data crossing the network; inside the page, the choice that matters is clone versus transfer. The exception is a format whose decoded form is a typed array, which can be transferred.
Is Protobuf worth it for a browser application?
Only when it is already the network format, or when payloads are large and heavily numeric with a stable schema. The wire size and decode wins are real but modest against JSON in JavaScript engines, and the cost is a build step, a schema toolchain and 30–80 kB of runtime. If neither condition holds, the complexity buys little.

See also