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.
Fixtures
Section titled “Fixtures”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(),});| Fixture | Value | Bytes each |
|---|---|---|
| Person | one person, ASCII name | 8 |
| Unicode person | one person, name with 2- and 4-byte characters | 31 |
| Nested event | one event | 43 |
| 100 events | array of 100 generated events | 41 |
| 100,000 events, repetitive | the same array at 100,000 entries: three recurring names, two recurring tag sets | 43 |
| 100,000 events, high-entropy | the same 100,000 entries with a mostly unique name and two unique tags each | 70 |
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.
Raw bytes
Section titled “Raw bytes”| Codec | Person | Unicode person | Nested event | 100 events |
|---|---|---|---|---|
| shorn | 8 | 31 | 43 | 4,135 |
| Avro / avsc | 8 | 31 | 44 | 4,249 |
| SchemaPack | 9 | 32 | 44 | 4,235 |
| msgpackr shared records | 10 | 33 | 52 | 4,993 |
| Protobuf.js reflection | 11 | 34 | 57 | 5,684 |
| cbor-x shared records | 14 | 38 | 62 | 5,972 |
| @msgpack/msgpack | 23 | 46 | 115 | 11,281 |
| msgpackr plain | 25 | 48 | 121 | 11,881 |
| cbor-x plain | 26 | 50 | 122 | 11,972 |
| JSON | 35 | 58 | 163 | 16,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.
Compressed, 100,000 events
Section titled “Compressed, 100,000 events”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.
Repetitive data
Section titled “Repetitive data”| Codec | Raw | Gzip | Brotli q6 |
|---|---|---|---|
| shorn | 4,276,969 | 1,294,279 | 1,031,628 |
| SchemaPack | 4,376,969 | 1,310,861 | 911,527 |
| Avro | 4,485,280 | 1,333,773 | 1,096,771 |
| msgpackr shared records | 4,995,339 | 1,545,944 | 1,096,702 |
| msgpackr bundled strings | 5,262,688 | 1,468,427 | 1,075,095 |
| Protobuf.js | 5,826,966 | 1,402,471 | 1,054,692 |
| JSON | 16,498,152 | 1,769,924 | 1,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.
High-entropy data
Section titled “High-entropy data”| Codec | Raw | Gzip | Brotli q6 |
|---|---|---|---|
| shorn | 7,032,525 | 2,843,123 | 2,405,343 |
| SchemaPack | 7,132,525 | 2,956,080 | 2,559,420 |
| Avro | 7,240,836 | 2,933,473 | 2,529,981 |
| msgpackr shared records | 7,750,895 | 2,977,950 | 2,610,896 |
| msgpackr bundled strings | 8,053,257 | 3,078,433 | 2,245,961 |
| Protobuf.js | 8,532,522 | 2,992,114 | 2,471,879 |
| JSON | 19,153,708 | 3,288,544 | 3,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.
Cutting bytes further
Section titled “Cutting bytes further”Five schema choices shrink payloads further:
- Declare non-negative integers: ZigZag doubles the magnitude, so a signed
intcrosses 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()andz.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.
Reproduce these numbers
Section titled “Reproduce these numbers”pnpm bench # small fixturespnpm bench:large # 100,000 repetitive eventspnpm bench:entropy # 100,000 high-entropy eventspnpm bench:all # everything, plus correctness checksRun the whole benchmark on one machine when comparing results. Do not combine rows from different runs or environments.