Skip to content

Payload size

Size is where shorn leads. Every number here is from Node v24.18.0, Apple M4 Pro, macOS 26.6.2 arm64. Each table comes from a single run: raw bytes from pnpm bench, the compressed tables from pnpm bench:large and pnpm bench:entropy. Sizes from different runs are not comparable, so they are never mixed in one table.

Every benchmark on this site measures the same two shapes, defined once in bench/fixtures.mjs:

const person = m.object({ age: m.uint(), name: m.string(), sex: m.enum(["F", "M", "X"]) });
const event = m.object({
active: m.boolean(),
actor: person,
id: m.uint(),
metrics: m.object({ cpu: m.float64(), memory: m.uint() }),
tags: m.array(m.string()),
timestamp: m.uint(),
});
FixtureValueBytes each
Personone person, ASCII name8
Unicode personone person, name with 2- and 4-byte characters31
Nested eventone event43
100 eventsarray of 100 generated events41
100,000 events, repetitivethe same array at 100,000 entries: three recurring names, two recurring tag sets43
100,000 events, high-entropythe same 100,000 entries with a mostly unique name and two unique tags each70

An event is one small application record: a few numbers, a flag, a nested actor, a metrics pair, and a short tag array. The two large profiles differ only in their string content, which is the one part shorn does not shrink. Together they bracket the realistic range: repeated strings help every compressor, unique strings defeat them.

CodecPersonUnicode personNested event100 events
shorn831434,135
Avro / avsc831444,249
SchemaPack932444,235
msgpackr shared records1033524,993
Protobuf.js reflection1134575,684
cbor-x shared records1438625,972
@msgpack/msgpack234611511,281
msgpackr plain254812111,881
cbor-x plain265012211,972
JSON355816316,148

shorn is smallest or tied on every fixture, with two caveats. Bare shorn, Avro, and Protobuf payloads all need the correct schema outside the payload. And the shared-record sizes leave out their record table.

The Unicode row shows where the savings come from. shorn removes field names, tags, and syntax, but does not shrink string content. Payloads dominated by structure and numbers can be about 75% smaller than JSON. Payloads dominated by free text gain less.

4.28 MB of shorn bytes against 16.5 MB of JSON, a batch large enough that compression is a real decision rather than a rounding error.

CodecRawGzipBrotli q6
shorn4,276,9691,294,2791,031,628
SchemaPack4,376,9691,310,861911,527
Avro4,485,2801,333,7731,096,771
msgpackr shared records4,995,3391,545,9441,096,702
msgpackr bundled strings5,262,6881,468,4271,075,095
Protobuf.js5,826,9661,402,4711,054,692
JSON16,498,1521,769,9241,731,672

shorn is smallest raw and under gzip, and second under Brotli. SchemaPack is 12% smaller under Brotli while being 100,000 bytes larger raw, for a reason specific to this fixture.

id, timestamp and memory are counters. SchemaPack’s fixed-width big-endian integers leave their high bytes unchanged across thousands of records, and the compressor’s LZ77 stage matches those as long runs. shorn’s varints spend 40% fewer bytes on the same counter, but lead with the byte that changes on every record. Density and compressor-friendliness pull in opposite directions, and shorn is on the density side by design.

Compression CPU for the shorn payload: gzip 47.77 ms, gunzip 4.71 ms, Brotli q6 45.72 ms, unbrotli 6.20 ms.

CodecRawGzipBrotli q6
shorn7,032,5252,843,1232,405,343
SchemaPack7,132,5252,956,0802,559,420
Avro7,240,8362,933,4732,529,981
msgpackr shared records7,750,8952,977,9502,610,896
msgpackr bundled strings8,053,2573,078,4332,245,961
Protobuf.js8,532,5222,992,1142,471,879
JSON19,153,7083,288,5443,213,618

shorn is smallest raw and under gzip, and second under Brotli. Against JSON it is 63% smaller raw, 14% smaller under gzip, and 25% smaller under Brotli. msgpackr’s bundled-strings mode is 7% smaller under Brotli despite being 15% larger raw. It writes every string’s content into one contiguous region, and on data that is mostly unique strings, that is the layout Brotli exploits best.

Compression CPU: gzip 101.22 ms, gunzip 9.76 ms, Brotli q6 103.34 ms, unbrotli 13.78 ms.

One caveat: shorn is not smallest under every compressor. Under Brotli, SchemaPack wins on repetitive data and msgpackr’s bundled strings wins on high-entropy data. shorn is smallest raw and smallest under gzip in both profiles.

Five schema choices shrink payloads further:

  • Declare non-negative integers: ZigZag doubles the magnitude, so a signed int crosses every varint boundary at half the value.
  • Use enums, not free strings, for closed sets: one varint index instead of a length plus the text.
  • Prefer literals where a field is constant: zero bytes.
  • Declare a timestamp as a timestamp: z.iso.datetime() and z.date() are both 6 bytes, against about 25 for a plain string that happens to hold a date; see rich types.
  • Choose framing deliberately: use a 4-byte fingerprint for persistent data. Pinned RPC can stay bare, and a fingerprint carried in a header does not need to be repeated in the payload.
Terminal window
pnpm bench # small fixtures
pnpm bench:large # 100,000 repetitive events
pnpm bench:entropy # 100,000 high-entropy events
pnpm bench:all # everything, plus correctness checks

Run the whole benchmark on one machine when comparing results. Do not combine rows from different runs or environments.