Liking cljdoc? Tell your friends :D

Performance

Boring's performance depends on the data, the API, and how much of the result the application needs. These measurements cover full encoding and decoding, compression, and selective reads.

The timing tables below are recorded results, not a new benchmark run. Their original environment descriptions are retained where available. Do not combine figures from different tables as if they came from one run.

Methodology

The committed harnesses define the payloads and calls being compared:

HarnessMeasurement
published.cljPublic encode/decode APIs, wire sizes, compression, typed arrays
hako_ab.cljMatched reuse and copy tiers for boring and hako
nippy_bench.cljNippy's stress-data comparison
capability.cljSelected fields and columns versus full decoding
nav.cljCursor operations, sequence lookup, and index construction
ClojureScript comparisonReused boring and Transit readers/writers in Node

The published harness calls boring/encode, boring/decode, hako/encode, hako/decode, and Nippy's fast APIs. Internal object reuse is therefore library-dependent. Earlier descriptions called this a fresh-reader/writer comparison, which the current harness does not establish. Use hako-ab when the reuse tier itself is the question.

For new timing runs, use a quiet machine, warm all payload/codec combinations, and record the runtime, CPU, power profile, dependency versions, and commit. The published harness reports Criterium means; interleaved A/B harnesses use minimum-based statistics to reduce contention effects. Those statistics answer different questions.

Allocation measurements use getThreadAllocatedBytes where available. They report heap allocation, excluding off-heap arenas and other processes. Lower heap allocation can reduce GC pressure without implying lower total memory use or faster execution.

clojure -M:bench -m published
clojure -M:bench -m published size
clojure -M:bench -m hako-ab
clojure -M:nippy-bench
bin/bench

The :bench alias requires a locally installed hako comparison build and JDK 25. See bench/README.md for setup and individual harnesses. bin/bench saves output and machine details under target/bench/; when running a harness directly, retain its output with the revision and environment.

JVM, µs/op

Recorded with Nippy 3.9.0-beta1 and the power-saver profile. The original table does not carry a complete machine/commit record, so use it as historical workload evidence and rerun before making a deployment decision. Lower times are better.

payloadopboringboring :shapeshakonippy
small-mapencode0.821.270.680.95
small-mapdecode0.921.130.710.97
mixedencode0.771.210.650.68
mixeddecode0.670.630.620.84
nested-map-50encode14.4215.1114.2013.97
nested-map-50decode22.1322.0514.0918.25
datom-maps-200encode67.4872.3552.4457.98
datom-maps-200decode69.3935.9438.9381.73
long-vec-1kencode15.6115.8016.7613.21
long-vec-1kdecode36.9136.0532.6034.56
str-maps-200encode63.4366.7082.4574.97
str-maps-200decode69.5554.5879.51106.44

In this run, boring with shapes has the lowest datom-map decode time. Hako leads the small-map and nested-map decodes; boring leads the string-keyed-map cases. The nested-map-50 fixture is a map containing 50 small maps, not a chain 50 levels deep.

Wire size

Sizes in bytes, rerun on 2026-09-06 at d14ad50, JDK 25.0.1, with Nippy 3.9.0-beta1 and hako 1.0.0-alpha35. The Nippy sizes differ from the previous table, so they are reported separately from the historical timings above. The raw size output records the command, revision, and dependency versions.

payloadboringboring :shapeshakonippy
small-map56564341
mixed63635553
nested-map-501 5611 5611 0791 191
datom-maps-2009 9524 9825 1655 592
long-vec-1k2 7262 7262 7402 746
str-maps-2007 5504 5709 74111 336

Shapes reduce repeated keys in the two arrays of maps. They do not apply to nested-map-50 because its maps are values of a map rather than rows of one array.

Tier-matched: hako-ab, both codecs reused

Recorded with power-saver, interleaved A/B measurements, minimum over 60 rounds and median of three runs. Ratio is boring time divided by hako time: below 1 favours boring.

payloadencode (T3, reused, no copy)decode (T2, reused reader)
small-map1.64× hako1.37× hako
mixed1.67× hako0.63× boring
nested-map-501.36× hako1.46× hako
datom-maps-2001.46× hako1.77× hako
datom-vec-1k1.72× hako0.50× boring
long-vec-1k0.89× boring1.20× hako

T3 encoding reuses buffers without copying the result; T2 decoding reuses the reader. These differ from allocating independent byte arrays through a convenience API. The recorded results favour hako for map-heavy encoding and several decodes, and boring for mixed-value and datom-vector decoding.

Heap allocation is a separate comparison: hako's native arena is not included in the thread-allocation counter. Consult the harness output for time and allocation at each tier.

Compressed

Sizes from the same rerun after zstd level 3, except Nippy's column, which uses nippy/freeze with its default compression policy. That policy may leave a value uncompressed. Include compression only when the application uses it; uncompressed data can be useful for direct navigation.

payloadboring+zstdboring :shapes+zstdhako+zstdfressian+zstdnippy/freeze (default)
small-map6565526045
mixed7272646957
nested-map-503543543883451 195
datom-maps-2001 1211 2371 1681 0145 596
long-vec-1k1 8511 8511 8611 5032 750
str-maps-2001 0621 1681 1189822 489

Compression reduces the size differences between formats. Its choice matters: the Nippy column includes a different compressor and is not a codec-only comparison.

For datom-maps-200, shapes reduce raw size but increase zstd output from 1,121 to 1,237 bytes. Repeated keys are also useful input to a general-purpose compressor. This result does not establish that shapes always increase compressed size; the deflate experiment has a different result.

On Nippy's benchmark

clojure -M:nippy-bench uses Nippy's stress data, its reader/Fressian filter, and its timing loop. The recorded run used Nippy 3.9.0-beta1 and power-saver.

codecfreeze µsthaw µsround µsbytes
nippy/fast5841 0751 65914 017
boring7289341 66215 326
boring :shapes7469401 68615 326
nippy (LZ4)8931 1572 0507 835
nippy/encrypted9791 2892 2687 863
boring + zstd1 8121 4093 2214 900
boring :shapes + zstd1 8611 3823 2434 900
fressian5 1653 2868 45112 222
fressian + zstd5 9813 2989 2794 600
pr-str + read-string7 09410 07517 16915 880
nippy/lzma215 8047 16222 9663 700

The raw boring and nippy/fast round trips are close in this run. Boring's decode is faster and its encode slower. The compressed rows show the size/latency trade-off: boring plus zstd is smaller than Nippy's LZ4 output and takes longer to round-trip. Nippy's LZMA2 result is smaller again, with a substantially higher time.

Reading: byte arrays, buffers, and navigation

Boring uses one structural reader with source-specific access. Heap reads use the byte-array path. BufferSource accepts ByteBuffer on JDK 9+; SegmentSource supports memory segments and mapped files on JDK 22+.

There is no committed real-storage harness establishing a general speed ranking between those two off-heap sources. Measurements on tmpfs or native allocated memory do not establish disk-backed page-fault behaviour.

Reading a field without decoding the value it is in

This experiment asks each codec for the same selected values. Boring uses navigation; the hako and Nippy arms in this harness decode their whole stored value first. This compares those access paths, not just decoder throughput.

The boring settings are encode-indexed with {:shapes true :stringref true}. The harness calls that combination :store; it is not a valid :profile option.

Reproduce with clojure -M:bench -m capability:

5 000 rows × 5 fieldsboringhakonippy
size134 465 B138 766 B273 865 B
one field of one row0.94 µs236 µs1 005 µs
sum one column110 µs267 µs1 070 µs
filter on one column, project another114 µs267 µs1 070 µs
heap allocated, one field of one row4 048 B987 344 B
heap allocated, sum one column121 120 B1 107 320 B6 306 800 B

The point read skips most rows. The column operations visit every row but construct fewer objects and decode fewer fields.

The same recorded experiment reports 231 µs for boring's full-decode column scan, compared with 110 µs for navigation and 267 µs for hako's decode path. That additional boring baseline helps distinguish selective-access gains from codec differences.

Shapes and stringref can be combined. The smaller fixture from this harness has the following sizes:

200-row tablebytes
plain12 613
stringref only10 037
shapes only6 648
shapes + stringref5 063
hako5 165

These payloads differ from the published fixture; their byte counts should not be substituted into its tables.

Navigation

clojure -M:bench -m nav compares cursor operations with decoding first:

68 KB, 200 recordsnavdecode + get-inratio
get-in one leaf (heap)5.9 µs124 µs21×
count the top-level map0.08 µs121 µs1400×
reduce over all 200, one field each57 µs125 µs2.2×
get-in one leaf (mmap'ed)6.2 µs131 µs21×
locate a 1 MiB blob vs materialise it0.6 µs185 µs290×

A container's count is available in its CBOR header. A reduction visits all records, so it has less to skip than a single-field lookup. A byte string can be skipped from its length without visiting its payload.

For a sequence:

5 000 events, 360 KBnavdecode-seqratio
scan for matching events1 542 µs5 330 µs3.5×
first event only (early exit)3.9 µs2.2 µs0.6×

decode-seq is already lazy. When only the first small event is needed, constructing and probing a cursor costs more than decoding that event.

The navigator supports indexed string references in one document. Navigable sequences require stringref off; write-seq! enforces that. See Index for configuration and the trust boundary.

Mmap reads and writes

The recorded clojure -M:bench -m mmap experiment found selective mapping faster than one pread per item. For appending 200,000 items, a BufferedOutputStream took 130 ms, mapping 171 ms, and encode-only work 105 ms. These results favour buffered streaming for that append workload; they do not describe the cost of updating existing mapped fields.

Editing covers the latter case, including byte movement, index maintenance, and durability I/O.

Compression and lookup granularity

A random lookup must decompress its containing chunk before navigating it. This recorded zstd-level-3 experiment varied chunk size:

chunkcompressedrations/lookupvs raw
uncompressed15.4 MB1.00×1 4981.0×
4 KB1.59 MB9.7×5 4003.6×
64 KB1.22 MB12.7×55 98737×
256 KB1.21 MB12.8×201 755135×

Larger chunks improved compression but increased lookup cost in this fixture. Choose chunk size using expected reads, cache behaviour, and storage costs. Application-level chunk decompression and filesystem compression have different cache and I/O paths; these numbers are not measurements of ZFS or btrfs defaults.

ClojureScript

Recorded on Node v23.11 with reused readers and writers for boring and Transit. Times are ns/op.

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

The comparison alias also needs the local fress benchmark dependency described in deps.edn.

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 than Transit for every payload in this table, not necessarily smaller than JSON:

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 JSON uses the JavaScript engine's native parser before constructing ClojureScript values. Boring parses CBOR in JavaScript. In these results, Transit leads most general-purpose full-decode cases.

Shapes reduce repeated keys and their parsing work: datom-maps-200 decodes in about 53 µs with shapes versus 91 µs with Transit, with a smaller encoding. The numeric-vector decode also favours boring. These benefits do not extend automatically to maps stored outside arrays.

Comparing equivalent JSON results

JSON.parse produces plain objects with string keys. Boring and Transit produce ClojureScript collections and keyword keys. A separate recorded experiment includes that conversion cost:

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

For this five-key fixture, shaped boring is close to a specialised JSON conversion and faster than js->clj :keywordize-keys. Applications using plain JS objects or their own conversion code should compare against those paths rather than assume the generic conversion cost. These timings are from a separate run from the preceding table.

Design experiments

The following results explain implementation choices. They are retained as experiments rather than current comparative throughput claims.

Heap and off-heap access

A segment-only reader experiment increased full-decode time and recursive stack use. Profiling attributed substantial work to bounds and arena-liveness checks that a tight accessor microbenchmark had allowed the JIT to hoist. Keeping heap access in the shared reader avoided paying those checks on the byte-array path.

The original standalone accessor probe was not committed. Its timings should not be used to select a storage backend or infer the speed of a complete decoder.

Optimisations that did not work

On a small-document LMDB navigation workload, these recorded within-run comparisons used a loaded machine and the powersave governor:

attemptresult
Shared encoded-key cache (nav/context)2.4× faster (implemented)
Pack records into one indexed CBOR sequence0.80× (slower)
Compiled path: no Cursor, no probe lookup, primitive offsets0.78× (slower)
Reuse one Reader across documents via reset1.00× microbench, 0.92× in a real LMDB scan

The shared key cache eliminated repeated key encoding. The other changes reduced allocation or dispatch without improving the measured scan. For example, the compiled path reduced allocation from 208 to 80 bytes per document while taking longer. This is evidence about those paths, not a general rule that allocation never matters on the JVM.

Primitive arrays

The published harness also compares the same thousand integer values in vectors and JVM primitive arrays. Arrays use RFC 8746 typed encodings, allowing bulk reads; they also change the result type and numeric range.

The deterministic sizes are 2,726 bytes for a vector, 8,008 for long[], 4,008 for int[], and 2,008 for short[]. Run clojure -M:bench -m published for matching current decode timings. Use a narrower array only when the values fit and the application wants an array. Boring does not automatically transpose maps into typed columns.

A WASM scanner

The WASM experiment walks CBOR structure in C without constructing strings or ClojureScript values:

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×

The scanner was roughly twice as fast in this experiment. Relative to the complete JS decode measured in that run, replacing only the scan would save about 6–10% under an optimistic model that ignores result-handoff work. This is not a measured integrated WASM decoder, nor a ceiling for every possible WASM design. No WASM decoder is shipped.

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