Liking cljdoc? Tell your friends :D

Performance

Numbers, methodology, and the cases where boring loses.

Methodology

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.

JVM, µs/op

Lower is better. Bold is the winner.

payloadopboringboring :shapeshakonippy
small-mapencode0.140.220.200.24
small-mapdecode0.310.310.250.29
mixedencode0.130.210.180.27
mixeddecode0.210.210.180.22
nested-map-50encode5.295.604.087.01
nested-map-50decode7.087.114.348.06
datom-maps-200encode26.9115.4815.0545.02
datom-maps-200decode21.0111.4111.5449.97
long-vec-1kencode6.746.766.6510.91
long-vec-1kdecode11.0511.074.6510.23
str-maps-200encode23.1725.4823.3136.50
str-maps-200decode22.6313.7723.2038.86

Wire size, bytes:

payloadboringboring :shapeshakonippy
small-map56564341
mixed63635553
nested-map-501 5611 5611 0791 381
datom-maps-2009 9524 9825 16510 665
long-vec-1k2 7262 7262 7402 874
str-maps-2007 5504 5709 74111 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.

Compressed

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:

payloadboring+zstdboring :shapes+zstdhako+zstdfressian+zstdnippy (own LZ4)
small-map6565526045
mixed7272646957
nested-map-503543543883451 385
datom-maps-2001 1211 2371 1681 0142 488
long-vec-1k1 8511 8511 8611 5032 878
str-maps-2001 0621 1681 1189822 493

Two things worth noting:

  • Compression erases most of the uncompressed size differences. boring's 9 952-byte 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.

On nippy's benchmark

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:

codecfreeze µsthaw µsround µsbytes
boring28531059515 326
boring :shapes28831560315 326
nippy/fast39953393217 105
nippy (LZ4)5275751 1028 518
boring + zstd6374651 1024 900
boring :shapes + zstd6414601 1014 900
nippy/encrypted5466181 1648 546
fressian1 6471 2132 86012 222
fressian + zstd2 0071 1173 1244 600
pr-str + read-string2 2203 1495 36915 880
nippy/lzma26 3362 7439 0793 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.

Where boring loses

Deeply nested maps — 1.4× bigger, 1.6× 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.

A plain vector of integers — 2.4× slower

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:

representationbytesdecode
vector of 1000 ints2 72611.10 µs
long[] (tag 79)8 0081.25 µs
int[] (tag 78)4 0080.69 µs
short[] (tag 77)2 0080.34 µs
hako vector2 7404.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.

ClojureScript

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.

Decode, ns/op

payloadboringboring :shapestransitJSON.parse
small-map2 2532 2441 055224
mixed1 4701 475627172
string-10034935013546
nested-map-5043 20743 19224 9976 991
datom-maps-200199 11152 82091 04233 727
datom-vec-1k242 767240 819152 31397 903
long-vec-1k10 03010 09412 7335 609

Encode, ns/op

payloadboringboring :shapestransitJSON.stringify
small-map1 7021 825895123
nested-map-5041 87041 65225 2633 557
datom-maps-200194 045124 680128 43520 144
datom-vec-1k705 819729 565449 12371 121
long-vec-1k40 96141 06139 0418 427

Size, bytes

boring is smaller on every payload:

payloadboringboring :shapestransitJSON
small-map56567548
nested-map-501 5611 5612 1761 621
datom-maps-2009 9524 98214 30713 091
datom-vec-1k25 74825 74839 00040 991
long-vec-1k2 7262 7263 8913 891

Why transit wins on JS, and where it does not

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 / dispatch31.8%
string decode (TextDecoder + UTF-8 validation)25.4%
collection building (transients)16.8%
keyword interning13.5%
other, GC12.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.
  • Dense numeric data. 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.

The JSON column is not the bar it looks like

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 objects34 348
JSON.parse + a hand-written CLJS build that knows the 5 keys43 420
boring :shapes45 127
boring, generic170 279
JSON.parse + js->clj :keywordize-keys197 026
CLJS construction alone, nothing parsed4 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.

A WASM decoder: measured, ~6–10%

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.

JSWASM
skeleton scan, generic (9 952 B)21 317 ns10 471 ns2.04×
skeleton scan, :shapes (4 982 B)9 806 ns5 242 ns1.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 decodesaving 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 moveKeyword, 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

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close