Numbers, methodology, and the cases where boring loses.
Everything below was taken with:
A quiet machine. Load average ≈ 4. An earlier round of numbers was taken on a loaded box and had to be withdrawn: run-to-run variance under load exceeded 60%, which is larger than most of the differences being measured.
Fresh objects per call, for every library. boring through
boring/decode, hako through hako/decode, nippy through
nippy/fast-thaw — each allocating its own reader per call. An earlier
version of our benchmark compared boring's reused, warm-cache reader
against hako's fresh one, which flattered boring and was not a fair
comparison.
criterium quick-benchmark, mean, for the published tables. The
interleaved A/B harness (bench/ab.clj) reports a minimum of 8 rounds
instead, because it exists to compare two boring variants under load, where
the minimum approximates the uncontended cost and the mean measures the
machine. Do not mix figures from the two.
Allocation measured with getThreadAllocatedBytes, which is
deterministic and immune to load, wherever a claim can be made about
allocation instead of time.
A warm process, not a warm cell. Per-cell warmup is not enough: hako's small-map encode measured 2.52 / 1.30 / 1.15 / 1.08 µs across four consecutive runs of the same cell, so whichever cell runs first is penalised and the first block of any table is fiction. The suite warms every payload through every codec before it measures anything. Two byte-identical boring variants once timed 76% apart without this.
Reproduce with:
clojure -M:bench -m published # exactly the tables on this page
clojure -M:bench -m published size # the deterministic sections only; seconds
clojure -M:nippy-bench # nippy's own benchmark, unmodified
bin/bench # the wider suite; several minutes
Every table below is emitted verbatim by one of the first three commands. They
used to be hand-maintained, and had drifted: the wire-size table quoted 6 951
bytes for a datom-maps-200 that now measures 9 952, because the payload
definition it was taken from was never committed. The payloads live in
bench/published.clj now.
Output lands in target/bench/<timestamp>.txt with the machine description at
the top, because a benchmark number without its hardware is a rumour. Sizes run
first on purpose — they are deterministic, so they are the one section worth
keeping from a run that turns out to be too noisy to trust. bench/README.md
covers the individual harnesses.
Lower is better. Bold is the winner.
| payload | op | boring | boring :shapes | hako | nippy |
|---|---|---|---|---|---|
| small-map | encode | 0.14 | 0.22 | 0.20 | 0.24 |
| small-map | decode | 0.31 | 0.31 | 0.25 | 0.29 |
| mixed | encode | 0.13 | 0.21 | 0.18 | 0.27 |
| mixed | decode | 0.21 | 0.21 | 0.18 | 0.22 |
| nested-map-50 | encode | 5.29 | 5.60 | 4.08 | 7.01 |
| nested-map-50 | decode | 7.08 | 7.11 | 4.34 | 8.06 |
| datom-maps-200 | encode | 26.91 | 15.48 | 15.05 | 45.02 |
| datom-maps-200 | decode | 21.01 | 11.41 | 11.54 | 49.97 |
| long-vec-1k | encode | 6.74 | 6.76 | 6.65 | 10.91 |
| long-vec-1k | decode | 11.05 | 11.07 | 4.65 | 10.23 |
| str-maps-200 | encode | 23.17 | 25.48 | 23.31 | 36.50 |
| str-maps-200 | decode | 22.63 | 13.77 | 23.20 | 38.86 |
Wire size, bytes:
| payload | boring | boring :shapes | hako | nippy |
|---|---|---|---|---|
| small-map | 56 | 56 | 43 | 41 |
| mixed | 63 | 63 | 55 | 53 |
| nested-map-50 | 1 561 | 1 561 | 1 079 | 1 381 |
| datom-maps-200 | 9 952 | 4 982 | 5 165 | 10 665 |
| long-vec-1k | 2 726 | 2 726 | 2 740 | 2 874 |
| str-maps-200 | 7 550 | 4 570 | 9 741 | 11 465 |
boring beats nippy on all twelve timing cells, and on size for the two payloads
where the shape machinery applies. Against hako — an experimental codec built
for speed — it wins the small-payload encodes outright, ties datom-maps-200,
wins str-maps-200 decode by 1.7× at 2.1× smaller on the wire, and loses
the nested-map and integer-vector rows (see below).
The four small cells are close enough that the ordering is not stable across machines; treat 0.13 vs 0.18 µs as "the same". The gaps worth reading are the ones with a stated cause.
The row that actually matters for a storage or wire codec, since konserve, kabel
and every HTTP transport compress. zstd level 3, and nippy's own built-in LZ4
for the nippy/freeze column:
| payload | boring+zstd | boring :shapes+zstd | hako+zstd | fressian+zstd | nippy (own LZ4) |
|---|---|---|---|---|---|
| small-map | 65 | 65 | 52 | 60 | 45 |
| mixed | 72 | 72 | 64 | 69 | 57 |
| nested-map-50 | 354 | 354 | 388 | 345 | 1 385 |
| datom-maps-200 | 1 121 | 1 237 | 1 168 | 1 014 | 2 488 |
| long-vec-1k | 1 851 | 1 851 | 1 861 | 1 503 | 2 878 |
| str-maps-200 | 1 062 | 1 168 | 1 118 | 982 | 2 493 |
Two things worth noting:
datom-maps-200 and nippy/fast's 10 665 both land near 1.1 KB.
Only nippy's own LZ4 is meaningfully worse, and that is LZ4 versus zstd, not
a format property. Choosing a codec on uncompressed size is choosing on a
number your storage layer deletes.:shapes is a small loss under compression — 1 237 against 1 121 on
datom-maps-200. It removes exactly the repetition zstd is best at, and
replaces it with a header zstd cannot exploit. :shapes is a win when you do
not compress (2× smaller, and 1.8× faster to decode) and roughly a 10% cost
when you do. The same reasoning applies to stringref, which is why it is on
by default but not load-bearing.fressian+zstd is 5–20% ahead of boring+zstd on four of six payloads. That is a real, if small, loss, and it is the entire remaining size argument for fressian.
clojure -M:nippy-bench reruns nippy's own benchmark — nippy's stress-data,
nippy's reader+fressian filter, nippy's timing loop — across all six codecs
nippy reports, plus boring and the compressed tiers:
| codec | freeze µs | thaw µs | round µs | bytes |
|---|---|---|---|---|
| boring | 285 | 310 | 595 | 15 326 |
boring :shapes | 288 | 315 | 603 | 15 326 |
| nippy/fast | 399 | 533 | 932 | 17 105 |
| nippy (LZ4) | 527 | 575 | 1 102 | 8 518 |
| boring + zstd | 637 | 465 | 1 102 | 4 900 |
boring :shapes + zstd | 641 | 460 | 1 101 | 4 900 |
| nippy/encrypted | 546 | 618 | 1 164 | 8 546 |
| fressian | 1 647 | 1 213 | 2 860 | 12 222 |
| fressian + zstd | 2 007 | 1 117 | 3 124 | 4 600 |
pr-str + read-string | 2 220 | 3 149 | 5 369 | 15 880 |
| nippy/lzma2 | 6 336 | 2 743 | 9 079 | 3 888 |
Raw against raw, boring is 1.6× nippy/fast and 4.8× fressian on round-trip.
The size column needs the compressed rows to be read fairly: nippy/freeze
compresses above a size threshold, so its 8 518 is a codec plus a compressor
against boring's raw 15 326. Put both behind a compressor and boring+zstd
lands at 4 900 bytes in the same 1 102 µs nippy takes for 8 518 — 1.7×
smaller at equal time. nippy/lzma2 is smaller still at 3 888, for 8× the
round-trip. fressian+zstd is 6% smaller than boring+zstd and 2.8× slower.
nested-map-50 is 1 561 bytes against hako's 1 079, and decodes in 7.08 µs
against 4.34.
The cause is understood: 50 maps share a key set, but they are nested rather than collected into an array, so shaped arrays do not fire — 1 561 bytes either way. Stringref deduplicates the key strings, but every occurrence still pays a tag-39 identifier wrapper, a stringref reference and a map header.
Under compression the size half of this loss inverts: 354 bytes against hako's 388.
This is the case SHAPES.md specifies tag 39650 for. Measured on a comparable payload, define-then-reference takes a map-of-maps from 7 126 to 3 946 bytes (−44.6%). It is specified and deliberately not shipped in the first release.
long-vec-1k decodes in 11.05 µs against hako's 4.65. This is the largest
remaining gap in the table, and it is a decode-side gap only — encode is a tie
at 6.74 against 6.65.
Handing boring a primitive array instead of a vector changes the picture entirely, because it becomes an RFC 8746 typed array — a raw little-endian memory image:
| representation | bytes | decode |
|---|---|---|
| vector of 1000 ints | 2 726 | 11.10 µs |
long[] (tag 79) | 8 008 | 1.25 µs |
int[] (tag 78) | 4 008 | 0.69 µs |
short[] (tag 77) | 2 008 | 0.34 µs |
| hako vector | 2 740 | 4.61 µs |
short[] decodes 33× faster than the vector and is smaller on the wire.
On the JVM that is a bulk VarHandle read; in JavaScript it is a TypedArray
view over the buffer, which is genuinely zero-copy.
Unusually, the fastest path is also the most portable one: RFC 8746 is a registered standard that other CBOR libraries read natively.
Automatically narrowing a homogeneous integer vector to a typed array would
close this gap — measured at 5 144 bytes and 1.65 µs against 7 133 and 37.11
µs for a 512-row four-column payload, i.e. smaller and 22× faster. It needs
narrowest-fit selection per column to avoid long[]'s 2.3× size penalty, and
is specified in SHAPES.md as the columnar extension.
node v23.11, ns/op, reused writer and reader on both sides (transit's tw/tr
are created once, so boring's are too). Regenerate with:
clojure -M:cljs-compare -m cljs.main -co '{:language-in :ecmascript-next}' \
-O advanced -t node -o target/cljs-compare.js -c cljsbench.compare
node target/cljs-compare.js
An earlier version of this page claimed boring "beats transit-cljs on every
axis — 1.6x encode, 2.6x decode, 2.9x smaller". That is wrong as a general
claim. It holds for one payload shape, with :shapes enabled, and the reverse
holds elsewhere.
| payload | boring | boring :shapes | transit | JSON.parse |
|---|---|---|---|---|
| small-map | 2 253 | 2 244 | 1 055 | 224 |
| mixed | 1 470 | 1 475 | 627 | 172 |
| string-100 | 349 | 350 | 135 | 46 |
| nested-map-50 | 43 207 | 43 192 | 24 997 | 6 991 |
| datom-maps-200 | 199 111 | 52 820 | 91 042 | 33 727 |
| datom-vec-1k | 242 767 | 240 819 | 152 313 | 97 903 |
| long-vec-1k | 10 030 | 10 094 | 12 733 | 5 609 |
| payload | boring | boring :shapes | transit | JSON.stringify |
|---|---|---|---|---|
| small-map | 1 702 | 1 825 | 895 | 123 |
| nested-map-50 | 41 870 | 41 652 | 25 263 | 3 557 |
| datom-maps-200 | 194 045 | 124 680 | 128 435 | 20 144 |
| datom-vec-1k | 705 819 | 729 565 | 449 123 | 71 121 |
| long-vec-1k | 40 961 | 41 061 | 39 041 | 8 427 |
boring is smaller on every payload:
| payload | boring | boring :shapes | transit | JSON |
|---|---|---|---|---|
| small-map | 56 | 56 | 75 | 48 |
| nested-map-50 | 1 561 | 1 561 | 2 176 | 1 621 |
| datom-maps-200 | 9 952 | 4 982 | 14 307 | 13 091 |
| datom-vec-1k | 25 748 | 25 748 | 39 000 | 40 991 |
| long-vec-1k | 2 726 | 2 726 | 3 891 | 3 891 |
transit-cljs :json writes JSON text, so JSON.parse does the entire
byte-to-structure walk in native C++ and transit only pays for the JS-level
walk that builds Clojure values. boring parses binary in JavaScript. Profiling
datom-maps-200 decode (node --cpu-prof) puts boring's time at:
| share | |
|---|---|
| byte scan / dispatch | 31.8% |
string decode (TextDecoder + UTF-8 validation) | 25.4% |
| collection building (transients) | 16.8% |
| keyword interning | 13.5% |
| other, GC | 12.5% |
The first two — 57% of decode — are what V8 does natively for transit. That
is a structural disadvantage of binary-in-JS, not a defect, and it is why the
fastest JS CBOR codecs generate their decoders with new Function/eval (which
a strict Content Security Policy forbids, and which boring does not do).
boring wins where the format advantage beats native text parsing:
:shapes on an array of same-shaped maps. Stripping the repeated keys
removes most of the scan and all of the repeated keyword interning:
datom-maps-200 decodes in 53 µs against transit's 91 µs — 1.7× faster, at
2.9× smaller — and encode is now ahead too, 125 µs against 128. This is the datom shape boring exists for.
It does nothing for nested-map-50 (maps nested, not collected) or
datom-vec-1k (vectors, not maps), which is why those rows are flat.long-vec-1k decodes in 9.8 µs against 12.6 —
CBOR integers are 1–3 binary bytes where JSON must parse decimal text. A
typed array widens this to more than an order of magnitude.Everywhere else — small maps, plain strings, nested maps — transit is 1.5–2.7× faster and boring is 1.3–1.4× smaller. If you are CPU-bound in a browser on generic data, transit is the faster choice today; boring's case on JS is wire size, cross-language reach, and the shaped-array path.
JSON.parse returns plain JS objects with string keys. boring and transit
return ClojureScript persistent maps with keyword keys. Those are different
jobs, and the difference is most of the apparent gap. On datom-maps-200:
| ns | |
|---|---|
JSON.parse → plain JS objects | 34 348 |
JSON.parse + a hand-written CLJS build that knows the 5 keys | 43 420 |
boring :shapes | 45 127 |
| boring, generic | 170 279 |
JSON.parse + js->clj :keywordize-keys | 197 026 |
| CLJS construction alone, nothing parsed | 4 297 |
With :shapes, boring is within 4% of JSON.parse doing the same job —
45.1 µs against 43.4, where the JSON side has been hand-specialised to the same
five keys boring's shape header carries. That is about as close to the native
parser as anything returning Clojure values gets.
Against what a CLJS app actually writes, boring wins outright. Nobody
hand-unrolls their keys; they call js->clj, and that costs 197 µs — 4.4×
slower than boring :shapes, and slower than boring's generic path too. For
reading into ClojureScript data, boring is the faster option today.
The last row decides what is left to optimise: building the result — 200
PersistentArrayMaps and a vector — costs 4.3 µs, under 10% of the shaped
decode. What remains is parsing, not construction.
An optional WASM module with a JS fallback is the obvious way to buy
native-speed scanning. bench/wasm/ is the experiment: a CBOR skeleton walker
in C that counts items and constructs nothing — the most a WASM module could
take off the JS decoder, since strings and Clojure values have to be built on
the JS side regardless.
| JS | WASM | ||
|---|---|---|---|
| skeleton scan, generic (9 952 B) | 21 317 ns | 10 471 ns | 2.04× |
skeleton scan, :shapes (4 982 B) | 9 806 ns | 5 242 ns | 1.87× |
WASM is genuinely ~2× at the scan, and the buffer copy is not the obstacle — 10 KB costs ~120 ns. The problem is the share:
| scan share of decode | saving if ALL of it moved | |
|---|---|---|
| generic (170 279 ns) | 12.5% | 6.4% |
:shapes (45 127 ns) | 21.7% | 10.1% |
That is a ceiling, not an estimate: it assumes JS consumes whatever index WASM writes for free, and a CBOR header is already one byte with the major type in its top three bits, so reading an index entry is not obviously cheaper than reading the header it replaces.
Two things pin the ceiling there. Strings cannot move — a JS string cannot
be a view into WASM memory, so every one is copied and transcoded UTF-8 →
UTF-16; this is the wall a Rust JSON parser in WASM hits at ~8× slower than
native JSON.parse, and boring's string time is already inside TextDecoder
either way. Clojure values cannot move — Keyword, PersistentArrayMap
and PersistentVector are JS objects WASM cannot allocate.
Against that, :shapes takes the same payload from 170 µs to 45 — 3.8× —
with no second implementation to keep conformant. Widening where shapes fire
(tag 39650, SHAPES.md) is worth more than a WASM scanner by a wide
margin, and nested-map-50 — where boring is furthest behind transit — is
exactly that case.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |