Benchmarks
Every number on this page comes from scripts in the repository, recorded in bench/results/ and bench/baseline.json, and turned into these charts and tables by pnpm bench:report. They were measured on one machine, named under each chart. Read them as relative guidance: the order and the ratios carry over to other hardware, the exact figures do not.
Compared with other libraries
pnpm bench:compare runs seven payloads through protoarray, JSON and eleven JavaScript codecs: the Protocol Buffers of protobufjs and protobuf-es, the Avro of avsc, the MessagePack of msgpackr and @msgpack/msgpack, the CBOR of cbor-x, MongoDB's BSON, Node's v8.serialize, the typed JSON of superjson and devalue, and Fastify's fast-json-stringify. Each library is used the way its documentation shows (bench/compare/adapters.js): the schema libraries get a schema of the same shape, a union is a protobuf oneof, an Avro union of records and protoarray's discriminated union. Every (library, scenario) pair runs in a fresh process, and an adapter has to give the scenario's value back through its own format before it is timed.
Speed is end to end: an object to text or bytes, and the text or bytes back to an object. A round trip is one of each. Pick a scenario and a metric; hover over or focus a bar for the time of one call, the ratio to JSON, the spread of the rounds and the exact version.
Every scenario: encode, decode and the payload sizes
One record — one record: 10 fields, nested address, tags, 2 orders
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 1,393,246 (×1.00) | 970,015 (×1.00) | 266 | 212 | 178 |
| protoarray codegen · v2.0.0 | 2,066,250 (×1.48) | 1,723,648 (×1.78) | 135 | 134 | 110 |
| protoarray closures, no eval · v2.0.0 | 1,500,853 (×1.08) | 1,065,375 (×1.10) | 135 | 134 | 110 |
| protoarray standalone module, no eval · v2.0.0 | 2,038,604 (×1.46) | 1,606,745 (×1.66) | 135 | 134 | 110 |
| protoarray codegen + metaschema check · v2.0.0 | 1,148,641 (×0.82) | 1,723,478 (×1.78) | 135 | 134 | 110 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 1,398,695 (×1.00) | 1,472,331 (×1.52) | 115 | 127 | 117 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 487,218 (×0.35) | 560,158 (×0.58) | 115 | 127 | 117 |
| avsc Type.forSchema · v5.7.9 | 1,626,234 (×1.17) | 1,738,513 (×1.79) | 97 | 111 | 101 |
| msgpackr pack · unpack · v2.1.0 | 1,187,227 (×0.85) | 703,240 (×0.72) | 206 | 207 | 173 |
| msgpackr records, shared structures · v2.1.0 | 1,413,209 (×1.01) | 2,605,367 (×2.69) | 108 | 124 | 112 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 743,786 (×0.53) | 682,968 (×0.70) | 198 | 200 | 166 |
| cbor-x records · v1.6.6 | 778,290 (×0.56) | 664,618 (×0.69) | 215 | 218 | 187 |
| BSON serialize · deserialize · v7.3.3 | 620,067 (×0.45) | 487,593 (×0.50) | 298 | 235 | 202 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 644,403 (×0.46) | 485,043 (×0.50) | 245 | 227 | 192 |
| superjson stringify · parse · v2.2.6 | 124,081 (×0.09) | 869,721 (×0.90) | 275 | 218 | 186 |
| devalue stringify · parse · v6.0.3 | 298,033 (×0.21) | 482,851 (×0.50) | 311 | 242 | 209 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 1,772,179 (×1.27) | 942,027 (×0.97) | 266 | 212 | 178 |
1,000 records — 1000 records of the same shape
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 1,466 (×1.00) | 891 (×1.00) | 271,215 | 33,793 | 29,133 |
| protoarray codegen · v2.0.0 | 1,958 (×1.34) | 1,476 (×1.66) | 140,738 | 30,666 | 26,946 |
| protoarray closures, no eval · v2.0.0 | 1,390 (×0.95) | 978 (×1.10) | 140,738 | 30,666 | 26,946 |
| protoarray standalone module, no eval · v2.0.0 | 1,992 (×1.36) | 1,471 (×1.65) | 140,738 | 30,666 | 26,946 |
| protoarray codegen + metaschema check · v2.0.0 | 1,090 (×0.74) | 1,462 (×1.64) | 140,738 | 30,666 | 26,946 |
| protoarray columns layout · v2.0.0 | 2,059 (×1.40) | 1,535 (×1.72) | 138,758 | 27,412 | 21,053 |
| protoarray columns, delta + intern · v2.0.0 | 2,245 (×1.53) | 1,670 (×1.87) | 114,965 | 24,574 | 19,799 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 1,301 (×0.89) | 1,403 (×1.57) | 120,459 | 31,414 | 28,985 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 617 (×0.42) | 587 (×0.66) | 120,459 | 31,414 | 28,985 |
| avsc Type.forSchema · v5.7.9 | 1,399 (×0.95) | 1,793 (×2.01) | 101,839 | 29,895 | 27,334 |
| msgpackr pack · unpack · v2.1.0 | 1,219 (×0.83) | 726 (×0.81) | 210,061 | 34,139 | 30,555 |
| msgpackr records, shared structures · v2.1.0 | 1,538 (×1.05) | 2,867 (×3.22) | 112,274 | 30,957 | 28,370 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 781 (×0.53) | 702 (×0.79) | 202,061 | 33,895 | 30,367 |
| cbor-x records · v1.6.6 | 1,373 (×0.94) | 2,340 (×2.63) | 125,051 | 31,582 | 29,016 |
| BSON serialize · deserialize · v7.3.3 | 635 (×0.43) | 471 (×0.53) | 306,979 | 40,388 | 35,974 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 1,234 (×0.84) | 664 (×0.75) | 247,280 | 35,072 | 31,298 |
| superjson stringify · parse · v2.2.6 | 105 (×0.07) | 904 (×1.01) | 271,224 | 33,806 | 29,142 |
| devalue stringify · parse · v6.0.3 | 249 (×0.17) | 549 (×0.62) | 311,515 | 76,723 | 63,418 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 1,659 (×1.13) | 884 (×0.99) | 271,215 | 33,793 | 29,133 |
Sparse record — 20 optional fields, 3 present
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 6,800,381 (×1.00) | 4,562,955 (×1.00) | 33 | 53 | 36 |
| protoarray codegen · v2.0.0 | 7,466,131 (×1.10) | 7,541,369 (×1.65) | 23 | 43 | 27 |
| protoarray closures, no eval · v2.0.0 | 3,029,115 (×0.45) | 2,382,353 (×0.52) | 23 | 43 | 27 |
| protoarray standalone module, no eval · v2.0.0 | 6,712,097 (×0.99) | 7,171,599 (×1.57) | 23 | 43 | 27 |
| protoarray codegen + metaschema check · v2.0.0 | 2,089,158 (×0.31) | 7,340,173 (×1.61) | 23 | 43 | 27 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 5,568,923 (×0.82) | 9,670,688 (×2.12) | 11 | 31 | 15 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 966,102 (×0.14) | 2,827,757 (×0.62) | 11 | 31 | 15 |
| avsc Type.forSchema · v5.7.9 | 1,457,467 (×0.21) | 3,623,007 (×0.79) | 28 | 35 | 23 |
| msgpackr pack · unpack · v2.1.0 | 6,136,300 (×0.90) | 6,733,737 (×1.48) | 21 | 41 | 25 |
| msgpackr records, shared structures · v2.1.0 | 5,958,539 (×0.88) | 12,178,186 (×2.67) | 9 | 29 | 13 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 5,224,674 (×0.77) | 3,403,714 (×0.75) | 19 | 39 | 23 |
| cbor-x records · v1.6.6 | 3,036,913 (×0.45) | 3,620,365 (×0.79) | 27 | 47 | 31 |
| BSON serialize · deserialize · v7.3.3 | 3,279,163 (×0.48) | 4,089,815 (×0.90) | 33 | 53 | 33 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 1,238,524 (×0.18) | 1,367,302 (×0.30) | 28 | 48 | 32 |
| superjson stringify · parse · v2.2.6 | 797,123 (×0.12) | 3,232,153 (×0.71) | 42 | 62 | 40 |
| devalue stringify · parse · v6.0.3 | 1,733,712 (×0.25) | 2,408,284 (×0.53) | 41 | 61 | 43 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 14,537,667 (×2.14) | 4,708,885 (×1.03) | 33 | 53 | 36 |
Typed record — enum, boolean and date heavy record
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 821,191 (×1.00) | 2,898,740 (×1.00) | 123 | 120 | 94 |
| protoarray codegen · v2.0.0 | 4,717,208 (×5.74) | 4,844,478 (×1.67) | 36 | 46 | 36 |
| protoarray closures, no eval · v2.0.0 | 3,514,161 (×4.28) | 3,118,202 (×1.08) | 36 | 46 | 36 |
| protoarray standalone module, no eval · v2.0.0 | 4,666,737 (×5.68) | 4,696,261 (×1.62) | 36 | 46 | 36 |
| protoarray codegen + metaschema check · v2.0.0 | 2,803,310 (×3.41) | 4,851,985 (×1.67) | 36 | 46 | 36 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 3,837,319 (×4.67) | 12,143,747 (×4.19) | 13 | 33 | 17 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 1,272,891 (×1.55) | 2,952,236 (×1.02) | 13 | 33 | 17 |
| avsc Type.forSchema · v5.7.9 | 2,135,877 (×2.60) | 7,911,258 (×2.73) | 12 | 32 | 16 |
| msgpackr pack · unpack · v2.1.0 | 2,770,059 (×3.37) | 2,776,545 (×0.96) | 73 | 96 | 72 |
| msgpackr records, shared structures · v2.1.0 | 3,373,007 (×4.11) | 5,181,591 (×1.79) | 24 | 45 | 28 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 1,155,596 (×1.41) | 1,540,533 (×0.53) | 71 | 92 | 71 |
| cbor-x records · v1.6.6 | 1,768,803 (×2.15) | 1,529,636 (×0.53) | 79 | 100 | 80 |
| BSON serialize · deserialize · v7.3.3 | 1,871,497 (×2.28) | 1,338,309 (×0.46) | 98 | 104 | 82 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 1,012,846 (×1.23) | 1,126,288 (×0.39) | 89 | 109 | 90 |
| superjson stringify · parse · v2.2.6 | 291,201 (×0.35) | 789,994 (×0.27) | 179 | 153 | 126 |
| devalue stringify · parse · v6.0.3 | 604,182 (×0.74) | 1,043,080 (×0.36) | 142 | 137 | 112 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 5,208,951 (×6.34) | 2,808,826 (×0.97) | 110 | 107 | 83 |
Long strings — long Cyrillic strings
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 1,557,669 (×1.00) | 1,335,778 (×1.00) | 816 | 266 | 229 |
| protoarray codegen · v2.0.0 | 1,811,907 (×1.16) | 1,695,038 (×1.27) | 785 | 230 | 207 |
| protoarray closures, no eval · v2.0.0 | 1,673,013 (×1.07) | 1,557,716 (×1.17) | 785 | 230 | 207 |
| protoarray standalone module, no eval · v2.0.0 | 1,758,113 (×1.13) | 1,674,028 (×1.25) | 785 | 230 | 207 |
| protoarray codegen + metaschema check · v2.0.0 | 1,548,540 (×0.99) | 1,693,591 (×1.27) | 785 | 230 | 207 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 575,285 (×0.37) | 507,626 (×0.38) | 777 | 233 | 211 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 407,763 (×0.26) | 427,814 (×0.32) | 777 | 233 | 211 |
| avsc Type.forSchema · v5.7.9 | 491,511 (×0.32) | 769,652 (×0.58) | 774 | 231 | 210 |
| msgpackr pack · unpack · v2.1.0 | 840,197 (×0.54) | 212,834 (×0.16) | 800 | 263 | 234 |
| msgpackr records, shared structures · v2.1.0 | 862,533 (×0.55) | 240,954 (×0.18) | 775 | 230 | 206 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 377,771 (×0.24) | 690,308 (×0.52) | 798 | 260 | 233 |
| cbor-x records · v1.6.6 | 707,953 (×0.45) | 241,858 (×0.18) | 806 | 269 | 241 |
| BSON serialize · deserialize · v7.3.3 | 604,607 (×0.39) | 387,918 (×0.29) | 840 | 290 | 257 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 822,278 (×0.53) | 1,033,535 (×0.77) | 863 | 263 | 247 |
| superjson stringify · parse · v2.2.6 | 310,472 (×0.20) | 1,177,765 (×0.88) | 825 | 275 | 233 |
| devalue stringify · parse · v6.0.3 | 547,691 (×0.35) | 924,009 (×0.69) | 832 | 284 | 243 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 1,320,247 (×0.85) | 1,335,361 (×1.00) | 816 | 266 | 229 |
100 wrpc packets — 100 wrpc packets: calls, callbacks and events
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 51,915 (×1.00) | 24,070 (×1.00) | 8,834 | 852 | 630 |
| protoarray codegen · v2.0.0 | 66,308 (×1.28) | 49,455 (×2.05) | 4,332 | 685 | 501 |
| protoarray closures, no eval · v2.0.0 | 48,093 (×0.93) | 26,769 (×1.11) | 4,332 | 685 | 501 |
| protoarray standalone module, no eval · v2.0.0 | 65,648 (×1.26) | 47,442 (×1.97) | 4,332 | 685 | 501 |
| protoarray codegen + metaschema check · v2.0.0 | 33,902 (×0.65) | 49,890 (×2.07) | 4,332 | 685 | 501 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 24,172 (×0.47) | 37,015 (×1.54) | 3,691 | 778 | 609 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 10,961 (×0.21) | 15,383 (×0.64) | 3,691 | 778 | 609 |
| avsc Type.forSchema · v5.7.9 | 20,632 (×0.40) | 46,294 (×1.92) | 2,833 | 633 | 521 |
| msgpackr pack · unpack · v2.1.0 | 31,025 (×0.60) | 23,611 (×0.98) | 6,800 | 842 | 640 |
| msgpackr records, shared structures · v2.1.0 | 28,725 (×0.55) | 84,980 (×3.53) | 3,697 | 725 | 566 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 25,155 (×0.48) | 20,986 (×0.87) | 6,400 | 804 | 614 |
| cbor-x records · v1.6.6 | 23,920 (×0.46) | 50,532 (×2.10) | 4,401 | 841 | 683 |
| BSON serialize · deserialize · v7.3.3 | 16,962 (×0.33) | 15,135 (×0.63) | 9,742 | 1,248 | 929 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 42,445 (×0.82) | 17,679 (×0.73) | 7,772 | 872 | 693 |
| superjson stringify · parse · v2.2.6 | 4,248 (×0.08) | 24,268 (×1.01) | 8,843 | 860 | 633 |
| devalue stringify · parse · v6.0.3 | 9,382 (×0.18) | 16,310 (×0.68) | 8,202 | 1,721 | 1,345 |
32 doubles — 32 doubles (where binary formats shine)
| Library | encode ops/s | decode ops/s | bytes | gzip | brotli |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 323,242 (×1.00) | 483,381 (×1.00) | 764 | 406 | 392 |
| protoarray codegen · v2.0.0 | 406,134 (×1.26) | 733,235 (×1.52) | 582 | 309 | 295 |
| protoarray closures, no eval · v2.0.0 | 360,928 (×1.12) | 566,586 (×1.17) | 582 | 309 | 295 |
| protoarray standalone module, no eval · v2.0.0 | 404,218 (×1.25) | 731,900 (×1.51) | 582 | 309 | 295 |
| protoarray codegen + metaschema check · v2.0.0 | 336,196 (×1.04) | 737,943 (×1.53) | 582 | 309 | 295 |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 709,089 (×2.19) | 1,944,328 (×4.02) | 305 | 328 | 304 |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 252,782 (×0.78) | 455,256 (×0.94) | 305 | 328 | 304 |
| avsc Type.forSchema · v5.7.9 | 1,937,808 (×5.99) | 3,115,855 (×6.45) | 256 | 279 | 260 |
| msgpackr pack · unpack · v2.1.0 | 1,100,446 (×3.40) | 428,775 (×0.89) | 409 | 429 | 376 |
| msgpackr records, shared structures · v2.1.0 | 1,413,446 (×4.37) | 3,523,365 (×7.29) | 289 | 312 | 280 |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 731,077 (×2.26) | 187,123 (×0.39) | 409 | 429 | 376 |
| cbor-x records · v1.6.6 | 711,125 (×2.20) | 433,751 (×0.90) | 416 | 435 | 355 |
| BSON serialize · deserialize · v7.3.3 | 688,975 (×2.13) | 474,833 (×0.98) | 411 | 391 | 341 |
| v8.serialize structured clone, Node only · Node 24.14.1 | 644,279 (×1.99) | 594,638 (×1.23) | 443 | 387 | 340 |
| superjson stringify · parse · v2.2.6 | 87,844 (×0.27) | 449,255 (×0.93) | 773 | 420 | 399 |
| devalue stringify · parse · v6.0.3 | 230,286 (×0.71) | 265,668 (×0.55) | 853 | 454 | 443 |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 2,243,379 (×6.94) | 479,670 (×0.99) | 764 | 406 | 392 |
Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 400 ms rounds, each library and scenario in a fresh process · 2026-10-16. Medians; (×n) against JSON; bytes are the wire size of one payload (binary formats before any text encoding), gzip at level 6, brotli at quality 5. The spread of the rounds is in bench/results/compare.json.
Reading the numbers
- Records, strings and tagged messages are where JSON transports live, and where protoarray is the fastest encoder of the comparison:
JSON.stringifyis native, the generated code that turns an object into its array adds tens of nanoseconds, and the array is about half the text. The binary codecs written in JavaScript encode strings byte by byte and fall behind. - Decoding, protoarray is faster than JSON on every payload and faster than every codec on long strings, where
JSON.parsereads UTF-8 natively and the binary codecs decode it in JavaScript. msgpackr with shared structures (a structure id and the values, the binary equivalent of protoarray) decodes the other payloads faster thanJSON.parsecan read text, and avsc, cbor-x's records and protobufjs are ahead on some shapes (lists of records, enums and booleans). Over a round trip protoarray is first on one record, long strings and tagged messages, second behind msgpackr on lists and sparse records and behind protobufjs on typed ones. - Numbers are the honest exception: a double prints as up to 17 characters and parses back through a decimal conversion, where avsc and protobufjs read 8 bytes. Use a binary codec for dense numeric payloads.
- Validation. The wire is checked on decode and the values given to encode are trusted, so protoarray encodes the way protobufjs and msgpackr do, without checks, and decodes with them. Checking the values is metaschema's: the
codegen + metaschema checkrow runs metaschema'scheckof the same schema before every encode, which is what validating each outgoing value costs. - Without eval. The
closuresrow is the backend protoarray falls back to wherenew Functionis not allowed, at about the speed of JSON. Thestandalone modulerow is the same codec written into a module at build time (see Standalone Modules): what a strict CSP or an edge runtime gets instead, at the speed of generated code. - Sizes: protoarray's wire is JSON text; the binary formats' bytes are before any base64 a text channel would add. After gzip or brotli the formats are close, and the
columnslayout is the smallest on lists. With the ids written as differences and the cities and tags interned (columns, delta + intern), the thousand records are another 17% smaller before compression and the smallest after it of any format here; interning the unique strings (names, emails) instead would make the gzip larger, since their table compresses worse than the strings in place. - The typed JSON libraries (superjson, devalue) and v8.serialize need no schema: they write the keys and the type of every value, which is what they are for, and pay for it in speed and bytes. fast-json-stringify compiles a JSON Schema into a serializer and is the fastest encoder on sparse and typed records; it decodes with
JSON.parseand writes the keys.
Cold start
A schema is built once, so the tables above leave it out. A program that starts often (a CLI, a serverless function, a page) pays for loading the library, building its schemas and the first call, which runs before the engine has optimized anything. Each library runs ten times in a fresh process, loading itself, building the schema of one record, encoding it and decoding it once.
| Library | Load | Build | First encode | First decode | Total |
|---|---|---|---|---|---|
| JSON stringify · parse · Node 24.14.1 | 0.00 ms | 0.02 ms | 0.02 ms | 0.01 ms | 0.06 ms |
| v8.serialize structured clone, Node only · Node 24.14.1 | 0.27 ms | 0.01 ms | 0.04 ms | 0.04 ms | 0.36 ms |
| protoarray standalone module, no eval · v2.0.0 | 1.2 ms | 0.02 ms | 0.15 ms | 0.16 ms | 1.6 ms |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 2.9 ms | 0.10 ms | 0.37 ms | 0.57 ms | 3.9 ms |
| devalue stringify · parse · v6.0.3 | 5.1 ms | 0.01 ms | 0.49 ms | 0.26 ms | 5.9 ms |
| msgpackr records, shared structures · v2.1.0 | 5.3 ms | 0.03 ms | 0.63 ms | 0.43 ms | 6.4 ms |
| superjson stringify · parse · v2.2.6 | 5.8 ms | 0.01 ms | 0.58 ms | 0.04 ms | 6.4 ms |
| cbor-x records · v1.6.6 | 5.3 ms | 0.03 ms | 0.64 ms | 0.47 ms | 6.5 ms |
| BSON serialize · deserialize · v7.3.3 | 5.4 ms | 0.02 ms | 0.51 ms | 0.60 ms | 6.5 ms |
| protoarray codegen, from a snapshot · v2.0.0 | 3.9 ms | 3.0 ms | 0.14 ms | 0.15 ms | 7.3 ms |
| avsc Type.forSchema · v5.7.9 | 8.2 ms | 0.59 ms | 0.18 ms | 0.17 ms | 9.1 ms |
| protoarray closures, no eval · v2.0.0 | 11.6 ms | 3.8 ms | 0.11 ms | 0.17 ms | 15.7 ms |
| protobufjs reflection, Root.fromJSON · v8.8.1 | 11.1 ms | 1.5 ms | 3.1 ms | 0.35 ms | 16.2 ms |
| protoarray codegen · v2.0.0 | 11.7 ms | 4.5 ms | 0.14 ms | 0.16 ms | 16.5 ms |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 21.0 ms | 4.5 ms | 0.26 ms | 0.01 ms | 25.7 ms |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 24.5 ms | 0.38 ms | 0.80 ms | 0.06 ms | 25.9 ms |
A protoarray schema built from a definition loads metaschema too, which reads the definition first. A client that gets its schemas as snapshots from its server needs protoarray alone (from a snapshot), and a standalone module has its codec written already: loading it is the whole cost.
Bundle size
What a browser program pays to send and read that record with each library: an entry that imports what it uses, builds the schema the way the library documents and round-trips the value, bundled by esbuild for the browser (minified, tree-shaken) and checked to give the record back (pnpm size:compare). JSON costs nothing, and v8.serialize is Node only.
| Library | min | min+gzip | |
|---|---|---|---|
| devalue stringify · parse · v6.0.3 | 10.7 KB | 4.0 KB | |
| superjson stringify · parse · v2.2.6 | 11.1 KB | 4.0 KB | |
| @msgpack/msgpack Encoder · Decoder · v3.1.3 | 20.7 KB | 5.8 KB | |
| protoarray standalone module, no eval · v2.0.0 | 18.3 KB | 6.9 KB | the module protoarray compile writes for this schema, with its inline runtime |
| msgpackr records, shared structures · v2.1.0 | 27.4 KB | 10.3 KB | |
| cbor-x records · v1.6.6 | 28.6 KB | 10.6 KB | |
| protobuf-es create · toBinary · fromBinary · v2.16.0 | 66.6 KB | 18.2 KB | the runtime and the descriptor generated code embeds |
| protoarray codegen, from a snapshot · v2.0.0 | 60.9 KB | 22.0 KB | the schema built from its snapshot: a client without metaschema |
| BSON serialize · deserialize · v7.3.3 | 74.8 KB | 22.2 KB | |
| protobufjs protobufjs/light, Root.fromJSON · v8.8.1 | 105.7 KB | 29.2 KB | reflection; static code from pbjs with protobufjs/minimal is smaller, plus the generated code |
| avsc Type.forSchema · v5.7.9 | 115.4 KB | 34.3 KB | avsc's browser build of types, with the buffer and util polyfills it requires |
| protoarray codegen, metaschema definition · v2.0.0 | 120.0 KB | 42.6 KB | protoarray and @alexify/metaschema, which reads the definition and validates values |
| fast-json-stringify compiled stringify, JSON.parse · v7.1.0 | 217.4 KB | 62.9 KB | encode only; JSON.parse decodes |
Release over release
pnpm bench:releases measures the scenarios of the comparison through stringify and parse of every release, on both backends, with today's harness on one machine. 2.0 reads metaschema's definitions instead of its own spelling of them and kept the wire, the canonical form and the speed of 1.x: with generated code every scenario is within 7% of the first release that has it, and typed records encode a fifth faster (the text builder); the closures are within 10%, a sparse record 7–9% slower.
| Release | One record encode / decode | 1,000 records encode / decode | Sparse record encode / decode | Typed record encode / decode | Long strings encode / decode | 100 wrpc packets encode / decode | 32 doubles encode / decode | Schema.from schemas/s |
|---|---|---|---|---|---|---|---|---|
| 1.0.0 codegen | 2,030,000 / 1,650,000 | 1,960 / 1,500 | 7,200,000 / 7,340,000 | 3,850,000 / 4,770,000 | 1,790,000 / 1,660,000 | — | 403,000 / 734,000 | 36,200 |
| 1.1.0 codegen | 2,030,000 / 1,640,000 | 1,970 / 1,500 | 7,210,000 / 7,180,000 | 4,880,000 / 4,790,000 | 1,750,000 / 1,670,000 | 66,800 / 47,400 | 409,000 / 731,000 | 31,600 |
| 2.0.0 codegen | 2,040,000 / 1,690,000 | 1,970 / 1,480 | 7,020,000 / 7,460,000 | 4,670,000 / 4,780,000 | 1,800,000 / 1,700,000 | 67,600 / 50,600 | 427,000 / 731,000 | 14,600 |
| 1.0.0 closures | 1,550,000 / 971,000 | 1,420 / 937 | 3,180,000 / 2,590,000 | 2,950,000 / 2,850,000 | 1,670,000 / 1,540,000 | — | 360,000 / 573,000 | 172,000 |
| 1.1.0 closures | 1,470,000 / 1,040,000 | 1,380 / 976 | 2,930,000 / 2,460,000 | 3,540,000 / 3,110,000 | 1,680,000 / 1,550,000 | 47,800 / 26,000 | 364,000 / 568,000 | 98,400 |
| 2.0.0 closures | 1,470,000 / 1,060,000 | 1,390 / 978 | 2,950,000 / 2,360,000 | 3,450,000 / 3,090,000 | 1,690,000 / 1,560,000 | 48,100 / 26,800 | 359,000 / 567,000 | 24,000 |
Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 400 ms rounds, each release, backend and scenario in a fresh process · 2026-10-16. ops/s of stringify / parse, medians; — where the release has no union.
Building a schema is where 2.0 pays for metaschema: Schema.from(definition, { metaschema }) has metaschema read the definition first and protoarray read its snapshot after, about twice the time 1.1 took to parse its own spelling of it with generated code (about 70 µs for the record of the comparison) and four times with the closures, where the build is otherwise cheap. A schema is built once; one built from a snapshot skips metaschema, and a standalone module skips building.
protoarray on its two backends
pnpm bench is the engine harness for protoarray alone: both backends and every layout, against JSON.stringify and JSON.parse of the keyed object, with --save and --compare to track a change on one machine. The generated code is ahead of JSON on most shapes, ×1.2 to ×2.2. It is behind on a record of 70 flags, which travels as bitmask words JSON does not have to compute, on decoding bytes, where the base64 has to be canonical, and level with it on encoding a sparse record of 70 fields. The closures stay near JSON on most shapes and fall behind on sparse records, where they fill a decoded object one property at a time.
The layouts of a list of a thousand records: rows (an array per record, the default), flat (one array with a fixed stride), columns (an array per field) and columns with the ids as differences and the cities and tags interned. See Collections and Layouts.
pnpm bench also measures the text builder against the native stringify path with the text read once, as sending it would (see Runtimes), a resolved schema, an inline standalone module, parseItems against parsing the whole text, and Schema.from with each backend.
Running them
pnpm bench:compare --save # the comparison and the cold start, bench/results/compare.json
pnpm size:compare --save # bundle sizes, bench/results/size.json
pnpm bench:releases --ref v1.0.0 # a release (a tag), merged into bench/results/releases.json
pnpm bench:releases # the current tree
pnpm bench --save # the engine harness, bench/baseline.json
pnpm bench:report # the charts and tables of this page and the READMEEvery run is manual and none is part of CI. Run them on an otherwise idle machine: a 5% difference hides in the noise of a busy one. --scenarios and --adapters narrow bench:compare, and a saved run merges into the results, so one library or scenario can be measured again on its own.