Throughput
shorn leads both directions on every fixture except Unicode. Generated record encoders plus three fixes to the framing around them took Person encode past Avro, which was the last codec ahead of it. Unicode is the one holdout: Avro still leads its decode and msgpackr’s shared records its encode, both bound by TextEncoder/TextDecoder rather than by dispatch.
Against JSON
Section titled “Against JSON”JSON bytes converts to and from a Uint8Array, making it the direct comparison for binary transports. shorn wins every result while using 23–26% as many bytes for the ASCII fixtures and 53% for the Unicode fixture:
| Fixture | shorn enc | JSON enc | shorn dec | JSON dec |
|---|---|---|---|---|
| Person | 25.16M | 4.74M | 67.55M | 4.67M |
| Unicode person | 7.74M | 3.77M | 7.68M | 3.58M |
| Nested event | 8.64M | 1.40M | 11.45M | 1.74M |
| 100-event batch | 100.1k | 36.3k | 116.3k | 21.2k |
| Person, validated | 8.93M | 3.69M | 12.07M | 3.54M |
That is 2.1–6.2× encode and 2.1–14.5× decode. Even with Zod validation on both sides, shorn decodes 3.4× faster than JSON does with no validation at all.
JSON.stringify to a string reaches 10.82M/s for Person against shorn’s 25.16M/s, and stops at a JavaScript string instead of producing bytes. See shorn vs JSON.
Against the compiled schema codecs
Section titled “Against the compiled schema codecs”| Fixture | Op | shorn | Avro | SchemaPack | msgpackr records |
|---|---|---|---|---|---|
| Person | enc | 25.16M | 17.10M | 12.55M | 10.55M |
| Person | dec | 67.55M | 25.59M | 16.12M | 18.96M |
| Unicode person | enc | 7.74M | 5.77M | 6.98M | 8.04M |
| Unicode person | dec | 7.68M | 9.93M | 8.25M | 3.97M |
| Nested event | enc | 8.64M | 6.42M | 4.19M | 3.71M |
| Nested event | dec | 11.45M | 5.44M | 4.94M | 8.36M |
| 100 events | enc | 100.1K | 41.2K | 53.4K | 26.9K |
| 100 events | dec | 116.3K | 52.8K | 57.5K | 88.1K |
shorn now leads every column here except the two Unicode ones, where Avro leads decode and msgpackr’s shared records lead encode by 4%.
Validated end to end
Section titled “Validated end to end”| Codec | Bytes | Encode | Decode |
|---|---|---|---|
| shorn + Zod | 8 | 8.93M | 12.07M |
| Zod + Avro | 8 | 8.47M | 10.10M |
| Zod + SchemaPack | 9 | 7.00M | 7.90M |
| Zod + JSON string | 35 | 6.42M | 4.09M |
| Zod + JSON bytes | 35 | 3.69M | 3.54M |
With validation on both sides, shorn is about 2.42× faster to encode and 3.41× faster to decode than JSON bytes, while using 77% fewer bytes. This is now the fastest validated result measured in both directions, ahead of Avro by 5% on encode and 19% on decode.
Validation dominates both directions: the raw codec encodes at 25.16M/s and the validated path at 8.93M/s, so about 72 ns of the 112 ns is Zod. On decode it is 68 ns of the 83 ns. Further codec work moves an ever smaller share of what a validated round trip actually costs.
Why the codecs are fast now
Section titled “Why the codecs are fast now”An object schema with no optional fields builds its record decoder — and, since the encode-side counterpart landed, its record encoder — with new Function when the codec is constructed, so each such schema gets a function of its own instead of sharing an interpreted loop. That is what moved Person decode from 23.66M to 64.52M, and Person encode from 14.20M to 19.09M.
The win is not loop overhead. V8 allocates a feedback vector per closure creation site, so one shared helper accumulates the hidden classes of every object schema in the program and goes megamorphic — measured, a shared unrolled helper reads 2.7× faster than the loop with a single schema loaded and 3× slower once a dozen schemas share its call sites. Only a distinct function per schema keeps those call sites monomorphic.
The two sides attach that function differently, and the difference is not cosmetic. The decoder is shadowed onto the instance; the encoder is held in a field and dispatched from the prototype method. Shadowing _encode too gives every object schema a distinct function at the shared this.item._encode(...) call site inside ArraySchema, which tips it megamorphic — measured at −25% on an array of plain uints, a shape that contains no object schema at all. Routing through one prototype method costs about 7% of the object-encode win and hands the rest of the program its inline caches back.
Schemas with optional fields keep the interpreted path, because the presence bitmap makes the field set dynamic. Encoding additionally keeps it for schemas that reject unknown properties or carry a key shadowing Object.prototype, both of which need work before the field loop. A strict Content Security Policy falls back to the same path, with identical bytes and identical results. See Compilation and Caching.
Most of a small encode was never the schema
Section titled “Most of a small encode was never the schema”Generated encoders were worth 33% on Person and then stalled. Decomposing what remained found that _encode — the whole schema walk, every field, every leaf — was 25 ns of a 73 ns Person encode. The other 48 ns was the framing around it, and three fixes took Person from 19.09M to 25.16M:
| Cost | Was | Fix | Worth |
|---|---|---|---|
| Pooled-writer hand-off | take the module-level Writer, store undefined back | a boolean busy flag | 12.5 ns |
finish() | buffer.slice(0, offset) | allocate-and-copy below 16 bytes | ~7 ns |
Writer.string | one walk to total the UTF-8 length, a second to copy | one speculative walk | 7.6 ns on a 3-char string |
Each is worth recording for a different reason.
The pooled writer. Storing a Writer into a module binding and undefined back over it costs a write barrier each way. That pair measured larger than the entire interpreted field loop the generated encoders had just replaced. A boolean flag carries the same re-entrancy guarantee — an encode reached from inside another one still allocates its own Writer — as an oddball store with no barrier.
finish(). slice pays a fixed setup cost whatever the length, which is invisible on a 4 KB batch and is most of the work on an 8-byte record: 41.8 ns against 24.8 ns at 4 bytes. The crossover is 16 bytes and above it slice wins by more than 2×, so both paths stay.
Writer.string. Below 128 code units the length varint is one byte whatever the UTF-8 length turns out to be, and for an ASCII string that length is the code-unit count. So the length can be written before it is known to be right, and the loop that copies the bytes is the same loop that proves it: on the first unit ≥ 0x80 the offset rewinds and the general path runs. This is also why ASCII_ENCODE_INTO_LIMIT no longer describes a real crossover for the lengths it covers — 16 was measured when the copy ran after a full scan, and one walk beats scan-plus-encodeInto much further out than two walks did.
What is left is the per-leaf Writer call itself, which the generated source does not inline. That is now the largest remaining item, and it trades bundle bytes for speed like the two generators before it.
Rejected optimizations
Section titled “Rejected optimizations”Each measured as noise or worse:
| Change | Result |
|---|---|
Presizing the Writer | reallocation is 0.77% of a 4.3 MB encode; Person and Event never reallocate |
for..of → indexed loops | 0.12 ns across three fields |
| Decode results as object literals | −0.01 ns |
Lazy DataView, kept | a 9.7 ns regression on float payloads |
Module-level Float64Array scratch | looks worth ~50%; an escape-analysis artifact that vanishes once bytes are consumed (12% through shorn’s Reader, 8% through a Node Buffer) — and host-endian, so it would byte-reverse floats on a big-endian host |
| String output instead of bytes | base64 −31% throughput / +33% size; Latin-1 −25% and inflates on the wire |
The functional API is not slower
Section titled “The functional API is not slower”The functional API reaches 3.53M encodes/s, compared with 3.49M for compiled.encode. The difference is measurement noise. See Compilation and Caching.
Methodology
Section titled “Methodology”Tests ran on Node v22.23.1, an Apple M4 Pro, and macOS arm64. Small-fixture results are the median of seven samples of about 180 ms after warm-up. The 100,000-event results use three single-operation samples. Every codec must round-trip to the same logical value. Schema construction is excluded and measured separately as cold setup. Raw tests use each codec’s normal API with SchemaPack validation disabled. Protobuf.js includes fromObject and toObject to expose the same string-enum API.
Microbenchmarks are directional. Production decisions need representative schemas and traffic.