hako
August 3, 2026 · View on GitHub
A Modern, high-performance, Low-alloc Binary Serialization Library for Clojure.
Built on JDK 25 FFM MemorySegment. Quite possibly the fastest —
and lowest-allocation — binary serializer for Clojure on the JVM
today. See Benchmarks.
Highlights
- Zero runtime dependencies — only
org.clojure/clojure. - Off-heap by default —
MemorySegmentvia JDK 25 FFM. - Low GC pressure — arena buffer reused across messages, segment-out path allocates ~zero per call.
- Tunable — reusable Writer/Reader, caller-owned arenas, zero-copy byte decode, prim-array packing.
- Java hot path — per-value dispatch and primitive writes in Java
- Per-message symbol table — repeated keywords / symbols dedup to a 1-byte symref.
- Secure by default — no class loading from wire, no
Serializable, no decompression. See Security. - Extensible — records (Clojure + Java), user-tag registry with length-prefixed frames for forward-compatible reads.
Status
Pre-release, alpha.
Requirements
- JDK 25+ — uses
java.lang.foreignFFM API. Requires--enable-native-access=ALL-UNNAMEDon the JVM CLI (the packaged jar bundles theEnable-Native-Accessmanifest entry so application consumers don't see the warning). - Clojure 1.12+ — decoder probes
PersistentArrayMapthreshold at load, adapts to 1.13's bumped keyword-only limit automatically.
Install
Quick start
(require '[s-exp.hako :as hako])
(def bs (hako/encode {:name "Alice" :tags #{:a :b :c} :score 42}))
(hako/decode bs)
;; => {:name "Alice", :tags #{:a :b :c}, :score 42}
Note.
hako/encodeandhako/decodeare drop-in helpers to ease migration from other serializers — each call allocates a freshWriter/Reader(~500 B / ~250 B) and opens a private confinedArena. They are not the idiomatic way to use hako. Reach forencode-into!/decode-into!(reusableWriter/Reader) or the ThreadLocal-pooled variantsencode-pooled/decode-pooledfor hot paths. See the API section below and Performance.
API
Encoding
;; Migration-friendly one-shots — convenient, but allocate a fresh
;; Writer (~500 B) and confined Arena per call. Prefer the reusable
;; or pooled forms below on any hot path.
(hako/encode value) ; -> byte[]
(hako/encode value opts) ; -> byte[]
(hako/encode-to-segment arena value) ; -> MemorySegment (caller owns arena)
(hako/encode-to-segment arena value opts)
;; Idiomatic — reusable writer for high-throughput encode loops:
(with-open [wr (hako/writer 4096)]
(dotimes [_ 1000]
(let [seg (hako/encode-into! wr some-value)]
;; consume `seg` before the next call — the slice is
;; overwritten on the next encode-into!
...)))
;; Zero-alloc write straight into a caller-owned buffer (byte[] or
;; ByteBuffer). Skips the MemorySegment.asSlice wrapper — useful for
;; direct-to-LMDB / socket / mmap paths.
(hako/encode-into-buffer! wr dst value) ; -> byte count written
;; Batch API — multiple values share one symbol table:
(hako/encode-many [{:a 1} {:a 2} {:a 3}])
;; keyword :a is encoded once, symref'd twice.
;; Batch encode into a reusable writer (skips the ~500 B Writer setup
;; that `encode-many` pays per call):
(hako/encode-many-into! wr [{:a 1} {:a 2} {:a 3}]) ; -> MemorySegment
;; Pooled convenience — ThreadLocal-backed writer, opt-in:
(hako/encode-pooled value) ; -> byte[], zero setup alloc after warmup
;; Transducer-friendly decode over a batch stream:
(into #{} (filter :active) (hako/decoder bs))
(sequence (map :id) (hako/decoder bs))
(reduce + 0 (hako/decoder bs))
Encode options
| Option | Default | Description |
|---|---|---|
:initial-size | 256 | Starting buffer size in bytes. |
:preserve-meta | false | Preserve metadata on IObj values via the with-meta extension tag. |
:pack-homogeneous | false | Detect all-Long / all-Double vectors and emit them as packed prim arrays. |
:coerce-custom-comparator | false | Allow sorted-set-by / sorted-map-by — the custom comparator is dropped on decode. |
Decoding
(hako/decode src) ; src is byte[] or MemorySegment
(hako/decode src opts)
;; Reusable reader:
(let [rd (hako/reader some-src)]
(hako/decode-into! rd another-src))
;; Pooled convenience — ThreadLocal-backed reader, opt-in.
;; Skips the ~250 B per-call Reader alloc.
(hako/decode-pooled bs)
;; Cleanup for short-lived / servlet-container threads that use the
;; pooled variants. Closes the pooled Writer's confined Arena and
;; drops scratch buffers. Long-lived pooled threads never need this.
(hako/close-thread-locals!)
;; Batch — inverse of encode-many, returns a vector of values:
(hako/decode-many bs)
Decode options
| Option | Default | Description |
|---|---|---|
:zero-copy | false | Return MemorySegment slices for byte payloads instead of copying to byte[]. |
:tolerate-unknown-tags | false | Unregistered user-tag ids yield TaggedValue instead of throwing. |
:cache-idents | false | Consult a JVM-global cache when interning decoded keywords / symbols. |
Supported types
Semantic equality (=) is preserved for all listed types.
nil,boolean,Character,Long,Integer,Short,Byte,Double,Float,String.byte[],long[],double[],int[],float[],short[],char[],boolean[]— packed, component type preserved.Object[](and any reference-typed array — component type not preserved, decodes asObject[]).Keyword,Symbol— with per-message symbol table + symref dedup.UUID,java.util.Date,java.util.regex.Pattern(flags preserved),java.net.URI.java.time:Instant,Duration,Period,LocalDate,LocalTime,LocalDateTime,ZonedDateTime,OffsetDateTime.BigInteger,clojure.lang.BigInt,BigDecimal,Ratio.PersistentVector,PersistentList,PersistentHashSet,PersistentHashMap,PersistentArrayMap,ISeq.PersistentTreeSet,PersistentTreeMap— default comparator only; custom comparators cause a write error unless:coerce-custom-comparator true.PersistentQueue.- Clojure records via
defrecord(requires registration). - Java records (JEP 395; requires registration).
- User-defined types via
ext/register-user-tag!.
Concrete map / set impls may differ across Clojure versions — the
reader picks PersistentArrayMap vs PersistentHashMap based on
the runtime's threshold. See SPEC.md §5 for the roundtrip
contract.
Extensions
Records
(require '[s-exp.hako.ext :as ext])
(defrecord Point [x y])
(ext/register-record! Point)
(hako/decode (hako/encode (->Point 3 4)))
;; => #user.Point{:x 3, :y 4}
Registration reflects on the class once and caches a
MethodHandle for the canonical positional constructor.
Java records work identically:
public record Point(int x, int y) {}
(ext/register-record! com.example.Point)
User-tagged types
(import '(java.io File))
(ext/register-user-tag!
1 ; small app-local id (shifted to 0x10000001 on wire)
File
(fn write [w f] (.writeString w (.getPath ^File f)))
(fn read [r]
(let [tag (.getByte r)
low (bit-and tag 0x0F)
n (.readTierPayload r (int low))]
(File. (.getString r (int n))))))
(hako/decode (hako/encode (File. "/tmp/data.edn")))
;; => #object[java.io.File ...]
Frames are length-prefixed, so an unknown user-tag id can be
skipped by a :tolerate-unknown-tags reader without derailing the surrounding
message. See EXTENSIONS.md §E.2.
Metadata
Opt-in per encode:
(hako/encode (with-meta [1 2 3] {:tag :vec})
{:preserve-meta true})
Wire format
Byte-level spec in SPEC.md. Extension registry in EXTENSIONS.md. Byte-by-byte worked examples in WIRE_EXAMPLES.md. Highlights:
- 5-byte envelope
<magic 'HAKO'><version 0>. - Every value starts with a tag byte: high nibble = major type, low nibble = size tier or subtype.
- Fixed-width size tiers (inline
0..11, u8, u16, u32, u64) — no varint on the hot path. - Little-endian throughout.
- Per-message symbol table for interned keyword / symbol payloads.
- Zero shared state across messages.
Security
hako is designed for decoding untrusted input safely. Key guarantees:
- No arbitrary class loading. Records only instantiate classes
registered via
ext/register-record!— the wire carries a classname string, but hako looks it up in the registry rather than callingClass.forName. An attacker cannot force instantiation of arbitrary Java classes. - No arbitrary code execution via user-tags. User-tag ids
dispatch through the
register-user-tag!registry. Unregistered ids throw by default;:tolerate-unknown-tags truereturns an opaqueTaggedValue{:ext id :bytes segment-slice}— never invokes unknown code. - No Java
Serializablefallback. hako has no path toObjectInputStream. Deserialization gadget chains are not applicable. - No decompression. The wire format doesn't ship compressed payloads — no zip / gzip / snappy decompression on the read path, so no compression-bomb amplification vector.
- Per-message symbol table. Interning state is scoped to one message. A malicious message can't poison state for future decodes.
- Bounded reads. Count and length fields are validated against
remaining segment bytes before allocation. Silent truncation for
u64-tier counts that exceed
Integer/MAX_VALUEis rejected cleanly, not truncated. - Envelope enforcement. Magic + version bytes are checked before any dispatch.
- Confined memory. Encoder writes into
Arena.ofConfined()— cross-thread misuse is blocked by the FFM layer withWrongThreadException, not a memory corruption.
Not defended against (out of scope):
- Malicious user-tag write / read callbacks you register yourself. Registered code runs with your JVM's privileges — vet the callbacks you install.
- Denial-of-service via extreme payload sizes. hako reads what you give it; enforce input size limits at the transport layer.
Benchmarks
Criterium (quick-bench statistics: 6 samples × 100 ms target, 1.5 s
JIT warmup per call), JDK 25, -server -Xmx4g, direct-linking on.
Single machine, AC power — reproduce with clj -M:bench -m bench.
hako's core value proposition is off-heap encoding into a
MemorySegment with minimal Java-heap allocation. Four call
styles are measured; encode-into! uses a long-lived reusable
Writer (arena buffer amortized across messages), the others open
a fresh arena per call:
encode-into!→ seg — reusableWriter, emits aMemorySegmentslice per message. Nobyte[]allocation per call, no arena open/close per call. The differentiator — this is what the design is optimized for.encode-into!→ byte[] — same reusable Writer, then a trailingMemorySegment → byte[]copy for callers stuck onbyte[]APIs.encode→ byte[] — one-shothako/encode. Fresh confined arena + final off-heap → heap copy per call. Convenient, less optimal.encode-to-segment→ seg — one-shot with a caller-providedArena. Arena setup cost per call, but the result stays off-heap in the caller's arena.
Peers (JVM-only, on-heap output): nippy (default freeze,
compression + checksums), nippy-fast (fast-freeze, no
compression), deed (com.github.igrishaev/deed-core 0.1.0), and
transit (com.cognitect/transit-clj 1.0.333, MsgPack — different
niche, reference only).
Encode — hako call-style ladder
Absolute times per call. encode-into! → seg is the ceiling; other
columns show what each additional convenience costs.
| payload | encode-into! →seg | encode-into! →byte[] | encode →byte[] | encode-to-segment →seg |
|---|---|---|---|---|
long-array-1k | 0.20 µs | 0.72 µs | 0.87 µs | 0.64 µs |
double-array-1k | 0.19 µs | 0.71 µs | 0.84 µs | 0.61 µs |
string-100 | 0.03 µs | 0.04 µs | 0.09 µs | 0.14 µs |
small-map | 0.11 µs | 0.11 µs | 0.19 µs | 0.26 µs |
mixed | 0.20 µs | 0.22 µs | 0.29 µs | 0.39 µs |
string-10k | 0.74 µs | 1.07 µs | 1.30 µs | 1.23 µs |
vec-of-strings | 1.31 µs | 1.30 µs | 1.57 µs | 1.64 µs |
nested-map (50 kw) | 3.44 µs | 3.45 µs | 4.32 µs | 4.33 µs |
vec-of-longs (1k) | 5.74 µs | 5.89 µs | 6.32 µs | 6.42 µs |
The segment-out path wins by ~4.3× on prim arrays, ~1.8× on
long strings, and matches the byte[] paths on collection payloads
(where per-value dispatch dominates the arena/copy costs).
Numbers are the mean of two full runs; hako cells reproduce within
a few percent, peer-library outliers (e.g. nippy on string-10k)
vary more across runs.
Encode — encode-into! →seg vs peers
| payload | encode-into! →seg | nippy | nippy-fast | deed | transit | vs nippy-fast |
|---|---|---|---|---|---|---|
long-array-1k | 0.20 µs | 18.4 µs | 18.3 µs | 11.1 µs | 22.1 µs | 91× |
double-array-1k | 0.19 µs | 22.5 µs | 10.8 µs | 10.9 µs | 24.1 µs | 58× |
string-100 | 0.03 µs | 0.12 µs | 0.07 µs | 0.42 µs | 3.0 µs | 2.2× |
small-map | 0.11 µs | 0.31 µs | 0.26 µs | 0.61 µs | 3.6 µs | 2.4× |
mixed | 0.20 µs | 0.56 µs | 0.52 µs | 0.85 µs | 4.2 µs | 2.6× |
string-10k | 0.74 µs | 2.73 µs | 1.13 µs | 2.12 µs | 4.5 µs | 1.5× |
vec-of-strings | 1.31 µs | 2.33 µs | 2.47 µs | 3.61 µs | 6.96 µs | 1.9× |
nested-map (50 kw) | 3.44 µs | 10.1 µs | 10.1 µs | 16.1 µs | 36.3 µs | 2.9× |
vec-of-longs (1k) | 5.74 µs | 17.8 µs | 17.6 µs | 21.6 µs | 31.5 µs | 3.1× |
encode-into! →seg leads every cell — including string-10k
where the byte[]-output paths lose to nippy-fast.
Decode
Decode has two hako variants — one-shot hako/decode (byte[]
source, wrapped via MemorySegment/ofArray internally) and reused
hako/decode-into! (segment source, no wrap per call). Both take
{:cache-idents true}.
| payload | decode-into! →seg src | decode →byte[] src | nippy | nippy-fast | deed | transit | vs nippy-fast |
|---|---|---|---|---|---|---|---|
long-array-1k | 0.58 µs | 0.59 µs | 13.8 µs | 13.5 µs | 10.6 µs | 194 µs | 23× |
double-array-1k | 0.58 µs | 0.58 µs | 12.2 µs | 8.2 µs | 10.8 µs | 177 µs | 14× |
string-100 | 0.04 µs | 0.05 µs | 0.09 µs | 0.05 µs | 0.56 µs | 2.8 µs | 1.0× |
small-map | 0.15 µs | 0.25 µs | 0.24 µs | 0.18 µs | 0.79 µs | 3.3 µs | 1.2× |
mixed | 0.32 µs | 0.28 µs | 0.67 µs | 0.64 µs | 1.3 µs | 4.6 µs | 2.0× |
string-10k | 0.88 µs | 1.05 µs | 3.6 µs | 1.1 µs | 1.71 µs | 5.70 µs | 1.2× |
vec-of-strings | 2.64 µs | 2.68 µs | 3.83 µs | 3.07 µs | 8.24 µs | 16.4 µs | 1.2× |
nested-map (50 kw) | 6.12 µs | 5.93 µs | 14.4 µs | 14.3 µs | 25.8 µs | 55.2 µs | 2.3× |
vec-of-longs (1k) | 11.43 µs | 11.39 µs | 14.0 µs | 13.7 µs | 25.9 µs | 194 µs | 1.2× |
decode-into! →seg src leads every cell; string-100 is a dead heat
with nippy-fast (~1 ns apart) — its readUTF intrinsic is hard to
beat on payloads smaller than a cache line. See
Performance for the tradeoffs.
Allocation
Bytes allocated per operation, measured via
ThreadMXBean.getThreadAllocatedBytes over 100 000 iterations
post-warmup. Lower = less GC pressure on the consuming system.
Reproduce with clj -M:bench -m alloc-bench.
Encode — encode-into! →seg vs nippy/fast-freeze
| payload | encode-into! →seg | nippy-fast | vs nippy-fast |
|---|---|---|---|
| `long-array-1k$ | 40 | 37888 | 947 \times \text{less} |
| $string-10k` | 10056 | 20152 | 2.0× less |
| `ns-map$ (50 \text{kw}) | 40 | 21784 | 544 \times \text{less} |
| $string-100` | 160 | 352 | 2.2× less |
small-map | 88 | 288 | 3.3× less |
mixed | 96 | 504 | 5.3× less |
vec-of-strings | 2440 | 3472 | 1.4× less |
| `nested-map$ (50 \text{kw}) | 40 | 10000 | 250 \times \text{less} |
\text{Decode} — $decode-into!→seg src vsnippy/fast-thaw(measured with{:cache-idents true}`)
| payload | decode-into! →seg src | nippy-fast | vs nippy-fast |
|---|---|---|---|
long-array-1k | 8088 | 70976 | 8.8× less |
nested-map (50 kw) | 7792 | 40176 | 5.2× less |
small-map | 240 | 848 | 3.5× less |
string-10k | 10112 | 20192 | 2.0× less |
string-100 | 216 | 384 | 1.8× less |
mixed | 600 | 2344 | 3.9× less |
ns-map (50 kw) | 3664 | 24800 | 6.8× less |
vec-of-strings | 5808 | 8256 | 1.4× less |
encode-into! →seg wins allocation on every measured cell —
encode and decode. Keyword-heavy encode paths (nested-map,
ns-map) sit at the 40 B MemorySegment-slice baseline once
the Writer is warmed and the global keyword-bytes cache has been
populated. Use encode-into-buffer! to hand the encoded bytes
directly into a caller-owned byte[] / ByteBuffer — skips the
40 B slice wrapper for callers who own their output buffer.
This allocation delta is the tail-latency story — invisible to mean-of-loop timing benches. Under load (e.g. 100k msg/s), a 10 KB/op reduction per encode is 1 GB/s less young-gen churn on the consuming JVM. See Performance for tuning notes.
Records — 100 records in a vector
5-field Event record ({:id :ts :user :action :payload}), 100
instances in a vector. Reproduce with clj -M:bench -m records-bench.
| metric | encode-into! / decode-into! | nippy-fast | multiplier |
|---|---|---|---|
| encode | 8.7 µs | 60 µs | 6.9× |
| decode | 26 µs | 72 µs | 2.7× |
| size | 2933 B | 10088 B | 3.4× smaller |
Records are where the per-message symbol table pays off hardest.
hako emits the record classname + field-key keywords once per
message, then symrefs them (1 byte each) for the remaining 99
records. Nippy re-emits every keyword payload. Result: ~7×
faster encode, ~3× faster decode, ~3.4× smaller wire vs
nippy-fast. The win grows with vector length as the symref-vs-
payload ratio widens.
Nippy's default freeze compresses the output with Snappy and
comes in at 2173 B — smaller than hako's uncompressed 2933 B,
but at the cost of adding Snappy on the decode path (compression
bomb vector). Wrap hako in transport-layer compression if the
tradeoff makes sense for your setup.
Registration required — see Extensions §Records.
Encoded size
| payload | hako | nippy | nippy-fast | deed | transit |
|---|---|---|---|---|---|
nested-map (50 kw) | 732 B | 1632 B | 1628 B | 3598 B | 1128 B |
vec-of-longs (1k) | 2740 B | 2878 B | 2874 B | 10024 B | 2619 B |
long-array-1k | 8009 B | 2880 B | 2876 B | 8040 B | 2619 B |
double-array-1k | 8009 B | 4165 B | 8997 B | 8040 B | 9003 B |
vec-of-strings | 797 B | 896 B | 892 B | 1330 B | 793 B |
mixed | 46 B | 55 B | 51 B | 154 B | 54 B |
small-map | 37 B | 39 B | 35 B | 101 B | 34 B |
string-100 | 107 B | 106 B | 102 B | 140 B | 108 B |
string-10k | 10008 B | 61 B | 10003 B | 10040 B | 10008 B |
Two patterns dominate:
- Keyword-heavy structures: hako wins big —
nested-map(50 kw) is ~55% smaller than nippy. Per-message symbol table compresses repeat idents to 1-byte symrefs; peers re-emit the full ident each time. - Homogeneous numeric arrays: nippy wins via varint on
long-array-1k/double-array-1k. hako uses fixed 8-byte layout — same asMemorySegment.copyintrinsics, no per-element encode/decode branching on the hot path. The wire loss buys the perf win visible in the encode/decode tables above. - Large repetitive strings: nippy compresses
string-10kto 61 bytes via Snappy. hako intentionally does not compress — wrap in your transport-layer compression if you need it. Keeps the decode path free of decompression bomb amplification.
Everywhere else, hako is within a few bytes of the best peer.
Reproduce
clj -M:bench -m bench # full sweep (~15 min)
clj -M:bench -m bench nested-map # single payload
clj -M:bench -m quick # 5-payload triage bench, ~40 s
Documentation
Full user guides in docs/:
- Getting started
- API reference
- Supported types
- Extensions — records + user-tags
- Arenas & MemorySegment — memory model
- Streaming & batch —
encode-many, log-file patterns - Performance & tuning
- Thread safety
Wire-format specifications:
- SPEC.md — byte-level wire-format specification.
- EXTENSIONS.md — extension registry.
- WIRE_EXAMPLES.md — annotated byte-by-byte encoding examples.
Other:
- docs/migration-nippy.md — Nippy → hako guide.
- CHANGELOG.md — release notes.
Development
clj -T:build javac # compile Java sources → target/classes
clj -T:build javac-test # compile Java test-support classes
clj -M:test # run full test suite (currently 425 assertions)
clj -M:bench -m bench # criterium benchmarks vs peers (~15min)
clj -M:bench -m quick # 5-payload triage bench (~40s)
clj -T:build jar # build the release jar
License
Mozilla Public License 2.0 — see LICENSE.