Reaktor FlexBuffer — High-Performance Binary Serialization

reaktor-flexbuffer is Reaktor's Kotlin Multiplatform implementation of Google's FlexBuffers wire format. It provides two deliberately different capabilities:

  1. a generated, exact-layout FlexCoder tier built for maximum runtime speed; and
  2. a keyed kotlinx.serialization fallback built for model coverage and compatibility.

The distinction is the most important fact in this document. The generated path is the performance product. The raw serializer path is the coverage path. They share the same wire format and memory layer, but they do not have the same runtime cost or the same schema guarantees.

This page is the source-backed performance and adoption guide for Reaktor, BestBuds, Manna, JVM servers, Android, iOS, browser and Worker JavaScript, desktop, C++, and future native or WebAssembly boundaries. Refreshed 4 October 2026: the October performance and stability review is the current measurement ledger; the July matrices below remain historical. The complete From Bytes to Mutable Memory book is design input for wire understanding and a proposed mutable runtime, not a list of implemented Reaktor features.

1. Executive view

The current result (3 October 2026)

  • Two-fork JVM JMH generated encode/decode costs are 0.487 / 0.276 µs (UserProfile), 3.476 / 1.715 µs (ApiResponse), and 0.376 / 0.165 µs (TimeSeries). Full decode allocates result models, strings and collections.
  • The current campaign includes actual Chrome and physical S23 Ultra / iPhone 14 Pro runs. Generated decode performs well; Chrome UserProfile is roughly tied with kotlinx JSON and browser Unicode/StringFlood decode loses in the smoke suite.
  • JSON encoding wins several object-heavy cells, particularly in the browser. Native browser JSON works on a different object representation and is reported separately from Kotlin-model reconstruction.
  • kotlinx ProtoBuf is smaller in all four wire fixtures. Packed Google Java Protobuf is also measured on the host, including model-conversion costs.
  • Caller-owned buffers reduce output allocation; UserProfile caller-owned JVM encoding is slower in this run. A removed copy is not an automatic speedup.
  • Physical Android bulk-view experiments show substantial wins at 4,096 elements, up to 47.31x for float encode. Production Android bulk actuals still return false and use scalar fallback. Thresholds and release APKs remain to verify.
  • Five dedicated stability probes produced four failures. Correctness, lifetime, cache concurrency and schema validation precede production rollout.
  • Codec timings do not measure FFI hops or a mutable-memory engine. The native hello remains a proof; typed versioned FFI and mutable execution memory are designs.

See the complete tables and downloadable evidence for protocols, allocations, wire sizes, caveats and optimization priorities. Sections 6–9 retain the July campaign and integration evidence; their rankings apply to their own fixtures and schedules.

What is implemented, and what is not

CapabilityCurrent stateOperational meaning
Generated @Struct codersImplemented and measured; required-field/layout rejection defect openExperimental peak-performance tier for version-locked models
Generated zero-copy accessorsImplemented and testedBest for reading a few fields without materializing the model
Serializer API accelerationImplementedExisting serializer<T>() calls can route to a registered coder
Raw kotlinx Flex encoder/decoderImplemented, including sealed polymorphism; null/default defect openCoverage path requiring semantic regression checks
Caller-owned output bufferImplementedAvoids the final ByteArray copy while the builder remains owned and alive
C++ byte compatibilityVerified with byte/checksum harnessesKotlin and C++ can exchange FlexBuffers bytes
BestBuds complex-model benchmarkImplemented in modules/app/src/jvmTestMeasures the real sealed messaging model through raw Flex versus JSON
BestBuds production model transportNot migratedREST, WebSocket, and caches remain on their existing formats
Native bridge proofImplementedC++ hello map crosses JNI/cinterop and is decoded in Kotlin
Versioned typed FFI protocolNot implementedThe hello proof is not yet a production RPC contract
Manna FlexBuffer integrationNot implementedManna remains JSON/GraphQL/Worker-oriented today
Exact-layout schema fingerprintNot implementedPositional decode must not become the persistence default yet

The dependency reaches many Reaktor and BestBuds targets, but dependency reach is not adoption. No production @Struct model call site was found outside the FlexBuffer module's fixtures and profilers. BestBuds' only non-test FlexBuffer use is its developer-screen native hello verification. Reaktor service routers, BestBuds REST/WebSocket/cache paths, Manna, and Reaktor Desktop MCP remain JSON.

The architectural call

FlexBuffers should be Reaktor's fast self-describing byte substrate, not its universal answer to every serialization problem.

Use it when the dominant costs are parsing, allocation, repeated bridge calls, or materializing fields that the consumer will not read. Keep JSON where human inspectability, public APIs, browser-native string/object encoding, or third-party interop is the dominant requirement. Benchmark generated FlatBuffers when both endpoints already require and share a schema; it is expected to remain the encode ceiling for that case.

2. The big-picture runtime model

The high-performance design removes work at four layers:

model
  |
  | compile time: KSP fixes keys, field positions, types,
  |               key-blob-relative offsets, and decode geometry
  v
generated FlexCoder
  |
  | runtime: direct builder calls; no descriptor walk or per-field key lookup
  v
FlexBuffersBuilder
  |
  | platform kernels: direct stores, UTF-8 specialization, bulk primitive transfer
  v
one contiguous byte buffer
  |
  | optional boundary: network, storage, JNI, cinterop, JSI, worker, process
  v
generated decodeAt / zero-copy accessor / keyed fallback

There are two separate notions of zero-copy:

  • zero-copy output handoff means encodeToBuffer returns a view of the builder-owned backing array instead of copying into a new ByteArray;
  • zero-copy field access means a generated accessor reads fields directly from the encoded bytes instead of constructing the complete data class.

Generated full decode is not allocation-free. It avoids navigation-container allocation, but it still allocates the requested result objects, strings, and materialized collections. That is the correct semantic boundary.

3. Five access tiers and when to choose each

TierAPI shapeBest useMain cost or risk
1. Generated accessorbytes.asUserProfile().usernamePartial reads, routing, filtering, headers, indexesBuffer lifetime remains visible; repeated collection-wrapper construction must be avoided
2. Direct generated coderFlexBuffers.decode(UserProfileFlexCoder, bytes)Lowest-overhead full model materializationExact-layout and jointly deployed schema required
3. Registered serializerFlexBuffers.decode(UserProfile.serializer(), bytes)Drop-in acceleration at existing serializer call sitesStartup registration must be correct and complete
4. Raw serializer FlexSame serializer API without a registered coderSealed classes, unsupported shapes, compatibility workDescriptor and keyed navigation overhead
5. JSONJson.decodeFromString(...)Public APIs, logs, debugging, browser-native string/object encodeText conversion, parse, larger numeric payloads, full materialization

A practical decision tree

Is the payload public, third-party, or primarily human-inspected?
  yes -> JSON
  no  -> Is a mandatory shared schema already part of deployment?
           yes -> benchmark FlatBuffers and generated FlexCoder
           no  -> Is the payload ephemeral and both ends jointly deployed?
                    yes -> generated FlexCoder + fingerprint when available
                    no  -> raw/keyed Flex or another versioned compatible format

Does the consumer need only a few fields?
  yes -> generated accessor

Can the next layer consume bytes before a builder is reused?
  yes -> caller-owned builder / encodeToBuffer

Public API examples

Direct generated calls avoid reified lookup and serializer dispatch:

val bytes = FlexBuffers.encode(UserProfileFlexCoder, profile)
val decoded = FlexBuffers.decode(UserProfileFlexCoder, bytes)

The serializer route becomes the same fast path after registration:

UserProfileFlexCoder.register()

val bytes = FlexBuffers.encode(UserProfile.serializer(), profile)
val decoded = FlexBuffers.decode(UserProfile.serializer(), bytes)

A configured module registrar registers the complete generated set once:

ReaktorFlexbufferCoders.register()

The caller-owned path removes the final output copy. The final line below is pseudocode: the repository does not yet provide one universal ReadBuffer transport adapter.

val builder = FlexBuffersBuilder(16 * 1024, FlexBuffersBuilder.SHARE_KEYS)
val view = FlexBuffers.encodeToBuffer(UserProfile.serializer(), profile, builder)

// A concrete adapter must consume the view synchronously before clear/reuse.
transportAdapter.consumeReadBuffer(view)

4. Complete optimization ledger

This section records every retained runtime or code-generation change from the campaign. Rejected experiments are recorded separately so they are not accidentally reintroduced.

4.0 Direct memory and container foundations

The later optimizations work because the base reader and writer already avoid several abstraction layers:

FoundationRuntime effect
Platform ld8/16/32/64 and st8/16/32/64 functionsDirect little-endian scalar access without a virtual buffer call per value
Signed and unsigned width readersSign extension and offset/unsigned interpretation are separate, so a narrow negative is not zero-extended
Two-comparison width dispatchSelects 1, 2, 4, or 8-byte access without a long branch chain
ByteArray-backed Reference, Map, and VectorRemoves the old hot-path ReadBuffer interface dispatch
Precomputed vector type-array baseAvoids recomputing packed-type geometry on every element
Lazily cached map key-vector geometryPositional generated reads do not pay for key-vector navigation; keyed search computes it once
Typed-vector mode as a final fieldAvoids subclass/virtual dispatch for typed element reads
Direct builder-owned ByteArrayStores go straight to the growing output array
Parallel primitive ValueStack arraysAvoids allocating one object for every pending builder value
Reused scratch valuesAvoids temporary result objects while closing vectors and maps
One wide zero-padding storeReduces per-byte alignment padding work

The platform scalar implementations are intentionally different:

  • JVM and Android use unaligned Unsafe loads/stores when the runtime exposes them, with correct portable fallbacks.
  • Kotlin/Native uses unaligned Native memory intrinsics.
  • Kotlin/JS composes 1/2/4-byte integers without Long; floating conversion uses shared typed-array views.

These are unchecked hot-path primitives. Structural validation must first prove buffer bounds, depth, and root/container integrity. A separate fingerprint or version-locked out-of-band contract must prove the expected generated layout. Structural validity alone does not establish schema compatibility.

4.1 Generated root and dispatch geometry

OptimizationRemoved workWhy it matters
encodeRoot specializationNullable root-key resolutionEvery generated root encode starts with the known sentinel key offset
decode(bytes) specializationRoot Reference and Map navigationGenerated positional decode enters the byte array directly
decode(bytes, limit)Copying an array-backed sliceReadBuffer slices can decode from their actual trailer position
Explicit encode(coder, value) and decode(coder, bytes)Reified lookup and serializer discoveryLatency-critical code can call the generated object directly
Serializer-name registry routingFull AbstractEncoder/AbstractDecoder fallbackExisting kotlinx call sites can gain generated speed without changing call shape
Direct nested coder callsInterface and registry lookup for nested structsKSP emits calls to the concrete nested coder object
Root type geometry helpersGeneric root wrapper constructionFlexRead.rootEnd and rootByteWidth read the trailer directly
Root-kind guard for rootMapInterpretation of a non-map rootAccessors keep direct entry while checking the packed root kind; this is not full structural validation

The serializer-routed path is 1.005x the direct generated path geometrically in the final JVM headline matrix. In other words, registration makes serializer routing essentially free at the measured resolution.

4.2 Compile-time keys and map layout

Generated maps no longer perform ordinary dynamic key work.

  1. KSP reads the serialized wire name of every property.
  2. It sorts the names by unsigned UTF-8 byte order, the order used by C/C++ strcmp.
  3. It emits one null-terminated key blob and compile-time relative starts.
  4. The builder writes that blob once per type per output buffer.
  5. Every field uses keyBase + constant, with no hash lookup or absolute-offset array allocation.
  6. The first map writes a key vector; later maps of that type reuse it.
  7. The generated coder emits all fields, including nulls, so positional geometry stays fixed.

Retained changes:

Optimization or correctionRuntime/correctness effect
keyBlockBase plus compile-time startsRemoves a per-encode IntArray and one offset-array load per field
Shared key blobRemoves repeated UTF-8 key encoding
Shared key vectorRemoves repeated key-vector writes and width scans for repeated maps
Pre-sorted generated fieldsRemoves runtime map sorting
Correct relative element indexFixes nested vector/map width geometry when the stack slice does not start at zero
Correct typed-vector rangeChecks start + length, not length as an absolute end
Unsigned UTF-8 sort and compareMatches the wire specification and C++ for non-ASCII names
Property-level @SerialNameKeeps Kotlin identifiers independent from wire names
Class-level @SerialNameMakes serializer registry routing use the real descriptor name
Duplicate wire-name compile failurePrevents two properties from producing one ambiguous map slot

The key-block design analytically removes repeated work that the upstream C++ builder retains, so repeated-map shapes are an expected advantage. The current Kotlin/C++ harness does not isolate key-block geometry in a matched A/B, and the unique-string results must not be presented as proof of that specific cause.

4.3 Generated scalar decode

The generated decoder knows each declared scalar type. It therefore uses direct inline readers:

  • inlineInt
  • inlineLong
  • inlineFloat
  • inlineDouble

Those readers calculate end + index * byteWidth and load the value with the known signed or floating interpretation. They do not reload the packed type byte and branch through generic dispatch for each scalar.

KSP also:

  • uses the compile-time property count to locate the packed type array;
  • hoists child vector ends, widths, and type bases into locals;
  • enters nested generated coders through decodeAt;
  • reconstructs constructor argument order independently from wire-key order; and
  • generates Kotlin-safe identifiers for keyword or backtick-only source names.

This is exact-layout decode. It assumes the same serialized layout that the generated encoder emitted. That assumption is the source of both its speed and its schema-evolution limitation.

Generated accessors use the same fixed layout without constructing the data class. The accessor is a value-class façade over the map bytes, scalar properties read by known position, and collection properties expose lazy vector/map views. Callers should hoist a collection view out of a loop: repeatedly evaluating the property can repeatedly reconstruct the lightweight wrapper even though it does not copy the underlying payload.

4.4 Primitive vector width selection

FlexBuffers may store integer vectors in 1, 2, 4, or 8-byte elements. The width scan now:

  • includes the vector-size prefix in the required width;
  • knows each primitive type's maximum possible width;
  • returns immediately when that terminal width is reached;
  • handles ShortArray directly instead of converting it to IntArray;
  • handles UByteArray, UShortArray, UIntArray, and ULongArray directly; and
  • preserves scalar widening/sign extension for compact vectors whose wire width differs from the destination type.

Early termination is particularly useful for large arrays containing an early wide value. It avoids scanning thousands of later elements after the answer is already fixed.

Signed scalars and signed integer vectors use the smallest width that preserves two's-complement sign extension, matching C++ WidthI. A value such as -5 therefore occupies one byte rather than eight. Positive signed values whose high bit would look negative, such as 128 through 255, correctly require another byte. This improves wire size while retaining cross-language parity, and it is why reader-first deployment is mandatory for the signed-width upgrade.

4.5 Platform bulk primitive transfer

Natural-width primitive vectors can be copied as contiguous bytes. Compact vectors cannot: each element still needs widening or sign extension.

PlatformNatural-width policyReason
JVM/HotSpotBulk encode for Short, Int, Float, Double; bulk decode for Float, Double; Long encode and integer decode remain scalarMatched JMH showed C2 scalar loops beat Unsafe.copyMemory for the reverted cells
Android/ARTScalar for all primitive kernels todayBulk actuals return false; physical ByteBuffer A/B supports a proposed replacement, still unimplemented
Kotlin/Native/ApplePinned memcpy for all compatible primitive arraysNative primitive arrays are contiguous and Apple targets are little-endian
Kotlin/JSTyped-array transfer for Short, Int, Float, Double; Long remains scalarKotlin/JS LongArray is not a byte-compatible BigInt64Array

The implementation always has a scalar little-endian fallback. A platform kernel returns false when the reinterpretation is unavailable or unsafe.

Bounds checks use division:

elementCount <= (byteSize - offset) / elementByteWidth

instead of multiplying elementCount * elementByteWidth. This prevents integer overflow and, on Kotlin/JS, avoids lowering a hot range check through Long/BigInt. Under the same Node schedule, that JS change moved TimeSeries generated encode from 18.362 to 17.008 us/op and raw Flex encode from 23.211 to 20.479 us/op.

4.6 Primitive collection materialization

Generated read-only List<Int>, List<Long>, List<Float>, and List<Double> fields now materialize into primitive arrays wrapped by specialized AbstractList implementations.

This avoids storing every primitive as a separate boxed object in an ArrayList. It improves:

  • decode construction cost;
  • memory locality;
  • allocation volume; and
  • primitive traversal through specialized forEachValue methods.

Mutable collections and sets still use their required ordinary collection semantics. The optimization does not silently return a read-only wrapper where mutability was promised.

4.7 String encoding and decoding

Strings are target-specific enough that one universal implementation loses.

JVM

  • Compact LATIN-1 strings expose a byte backing array when supported.
  • Pure ASCII copies that array directly into the output.
  • LATIN-1 high bytes expand to two-byte UTF-8 without a UTF-16 intermediate.
  • UTF-8 length scans eight backing bytes per iteration and counts high bits.
  • Standard decodeToString is retained because HotSpot already optimizes it well.

The matched three-fork A/B measured User encode about 5.5% faster and API encode neutral for the word-at-a-time length scan.

Android

  • The portable UTF-8 encoder remains the write path.
  • Compact LATIN-1 length scanning is retained after a fully hot API 36 ART diagnostic measured 7.83 us versus 15.21 us for the portable length loop in the full caller-owned User encode.
  • Cold/interpreted ART samples were discarded.

That ART scanner diagnostic is recorded in the performance audit, not as a separate retained machine-readable result file.

JavaScript

  • TextDecoder reads UTF-8 from a Uint8Array view.
  • TextEncoder.encodeInto writes a known-length string directly into the output view.
  • The builder reuses the length it already computed instead of running a second independent encode strategy.

Kotlin/Native

  • ASCII encode/decode uses tight direct loops.
  • Non-ASCII decode falls back to Foundation NSString over pinned bytes.
  • Known-length ASCII writes avoid the generic encoder.
  • The design avoids a Kotlin closure in the hottest ASCII loop.

The tradeoff is code size and platform complexity. It is justified because string conversion dominates object-heavy payloads and each runtime exposes a different optimized primitive.

4.8 Key lookup

Map lookup has two paths:

  • ASCII query: compare characters directly to unsigned wire bytes with no allocation;
  • non-ASCII query: encode the query once, then reuse the bytes across all binary search probes.

The old non-ASCII path could create a scratch array and re-encode code points for every probe. The new path changes one lookup from repeated O(log n) encoding work to one encoding plus byte comparisons.

Map construction and lookup now both use unsigned UTF-8 ordering. This is a correctness requirement as well as a performance property: signed Kotlin Byte ordering is not C string ordering.

4.9 Raw kotlinx decoder hot path

The raw fallback is still important because real models may be sealed, contextual, nullable, or outside the current KSP-supported subset.

Its retained optimizations and fixes are:

ChangeEffect
Descriptor field-index cacheMaps descriptor index to sorted map position once
Direct currentMapIndex scalar readsAvoids a Reference allocation and another binary search
Direct currentVectorIndex scalar readsAvoids a Reference wrapper for list elements
Direct class/list/map structure entryOpens the pending map/vector child without materializing a generic reference first
Registered nested-coder dispatchLets supported nested values escape into generated decode
Bulk primitive-array decodeMaterializes typed vectors directly
Equal-count key verificationPrevents a renamed field from being silently decoded by position
Unsigned UTF-8 descriptor orderingMatches actual map ordering for Unicode wire names
Sealed/open structure handlingCorrectly reads the kotlinx {type,value} polymorphic envelope

The sealed-polymorphism correction was found by the BestBuds integration test. It now covers sealed values at the root, as a class field, in a list, and as a map value, including both data-class and data-object leaves.

Treating open-polymorphic descriptors as maps fixes structural navigation; it does not provide subtype registration. FlexEncoderV2/FlexDecoderV2 currently use EmptySerializersModule, and the public FlexBuffers API has no module parameter. Externally registered open polymorphism and custom contextual modules are therefore unsupported until that API changes. The BestBuds regression proves the sealed hierarchy handled by its built-in serializer, not arbitrary open polymorphism.

4.10 Builder ownership, pooling, and copies

The standard encode API returns a fresh ByteArray, so it must copy the final payload out of the reusable builder. That copy is useful ownership isolation and cannot be described as free.

encodeToBuffer and buildToBuffer expose the builder-owned view for callers that can obey a shorter lifetime. The view is invalid after clear or reuse.

Pooling policy:

  • the builder pool has 16 CAS-claimed slots;
  • each builder starts with 16 KiB, enough for the common headline fixtures without growth;
  • a fully warmed 16-slot pool has about 256 KiB of initial buffer capacity before retained growth; slots are allocated lazily;
  • contention may allocate a temporary builder instead of blocking; and
  • a full pool drops an extra builder for garbage collection.

Encoder/decoder instance pools are platform-specific:

PlatformPool primitive
JVM and AndroidPer-thread ThreadLocal slot
JavaScriptPlain module-local slot
Kotlin/NativeOne CAS-claimed AtomicReference slot

The Native CAS is a correctness fix. The previous volatile check-then-clear could loan one mutable encoder or decoder to two threads.

The ordinary ReadBuffer.toByteArray copy now respects an ArrayReadBuffer's offset and limit. This keeps slice correctness while allowing the generated decoder to avoid that copy entirely when it can consume the slice in place.

4.11 Reader and safety hardening

Not every retained change was a speedup. Some protect the assumptions required by the fast path:

  • Blob.get(size) is rejected; the valid range is 0 until size.
  • Invalid numeric strings return the documented zero value instead of throwing.
  • Signed typed integers retain their low bits correctly when exposed through unsigned arrays.
  • ByteArray.toFlexMap() validates that the packed root type is actually a map.
  • Generated exact-layout decode retains an unchecked trusted-buffer entry after structural safety and exact-layout compatibility have been established.
  • Raw float/double bits, NaNs, infinities, signed zero, empty vectors, compact widths, keyed vectors, and unsigned arrays have cross-platform coverage.
  • The public root-map helper checks the minimum trailer and packed root kind only; it does not run full structural validation. Untrusted input must pass FlexBuffers.isValid (or an equivalent boundary validator) before accessor use, and exact-layout compatibility must still be proven separately.

The performance rule is validate structure once, prove layout separately, then use the trusted internal path. FlexBufferValidator can establish structural ranges, depth, and types; it does not prove generated field order. Until layout fingerprints exist, untrusted bytes must remain on raw/keyed decode unless a version-locked out-of-band contract establishes the exact layout.

4.12 Registration and incremental code-generation correctness

Registration work does not dominate a hot call, but an incomplete registry can silently move a production call onto the slow path.

The processor now:

  • gives every generated coder an idempotent register() method;
  • can emit one configured module registrar at KSP finish();
  • sorts registrations deterministically;
  • makes that registrar aggregating and records all originating files;
  • tracks symbols across KSP rounds instead of generating from only the dirty round;
  • refuses serial-name collisions between different coders; and
  • uses class-level @SerialName for serializer routing.

The former top-level registerGeneratedFlexCoders() facade was removed because incremental processing could generate an incomplete facade. Use the configured module object or an individual coder's register() method.

5. Rejected optimizations

Rejected code is not left in the production path.

ExperimentResultDecision
JVM/Android per-thread builder pool2.6-5% slowerKeep the 16-slot CAS builder pool
Special-case materialized primitive wrappers during re-encodeOrdinary encode regressed 1.1-3.6%; decoded-model encode regressed 1.3-1.9%Reverted; retained only as a regression benchmark
JVM bulk integer materializationC2 scalar loop faster, including 1.97x for Short decode and 1.55x for Int decode at 4,096 elementsKeep scalar decode
JVM bulk Long storesScalar encode 1.32x faster at 4,096 elementsKeep scalar Long encode
Android primitive bulk transfer inferred from HotSpotART lacks the required object-to-object Unsafe copy; physical public-ByteBuffer A/B is availableMeasure public bulk views per type/size; release-APK verification remains open

These results are a reminder that fewer source lines or a native-looking memcpy is not automatically faster. The retained policy is cell-specific and evidence-based.

5.1 Memory and code-size tradeoffs

Runtime speed has priority, but the design does not treat memory as free:

ChoicePerformance benefitMemory/code tradeoff
16 pooled 16 KiB buildersAvoids allocation and common buffer growthAbout 256 KiB initial capacity when every lazy slot is warm; larger arrays may be retained
Generated coder per modelRemoves descriptor/key/type workMore generated source, bytecode/native code, and constant key blobs
Primitive-backed read-only listsAvoids boxed ArrayList elementsReturned primitive array and one wrapper still allocate
Caller-owned outputRemoves final payload copyExtends mutable builder lifetime and raises use-after-reuse risk
Accessor readsAvoids full model materializationKeeps the entire backing buffer alive while any accessor/view remains reachable
JS typed-array viewAvoids element conversionRetains the underlying ArrayBuffer; transferable ownership detaches the sender
Native owned result handleCan avoid a Kotlin copyNative memory remains outstanding until the exact handle is released
Shared keys/key vectorsReduces repeated wire bytes and workBuilder caches retain key metadata until clear/reuse

The correct memory metrics are not only B/op. Also track peak live bytes, pooled retained capacity, native-owned bytes, accessor-held buffers, and bundle or binary size.

The JMH fat jar is roughly 61 MiB because the module's broad api(:reaktor-core) dependency pulls unrelated framework stacks. That is a benchmark/build packaging problem, not evidence that every production FlexBuffer call consumes 61 MiB at runtime. Benchmark models and runners should move out of production commonMain, and the dependency surface should narrow.

6. Historical July JVM evidence

The tables in this section describe the 11–13 July campaign. For the current host, actual browser and physical mobile measurements, use the October review. The schedules differ; do not treat cross-campaign movements as a paired optimization A/B.

6.1 Headline matrix

Configuration: JMH 1.37, Temurin JDK 25.0.2, three forks, three 400 ms warmups, five 400 ms measurements, one thread, fixed 1 GiB heap, GC profiler. Values are us/op; B/op.

CaseDirect generatedCaller-ownedSerializer-routedRaw FlexJSONProtoBuf
UserProfile0.824; 3,2560.795; 2,4080.832; 3,2641.699; 4,3281.740; 4,5762.127; 12,744
ApiResponse5.218; 23,6245.093; 16,4965.263; 23,62415.371; 29,85621.071; 64,28015.317; 101,112
TimeSeries0.534; 8,6480.451; 4,2880.533; 8,6485.158; 26,38443.007; 103,82513.307; 43,496

Geometric mean versus direct generated:

PathRuntime ratioAllocation geometric mean
Caller-owned0.926x5,543 B/op
Direct generated1.000x8,729 B/op
Serializer-routed generated1.005x8,737 B/op
Raw serializer Flex3.884x15,051 B/op
ProtoBuf5.734x38,270 B/op
JSON8.818x31,258 B/op

The caller-owned TimeSeries path halves allocation from 8,648 to 4,288 B/op because the final byte-array copy is a large share of this primitive payload.

6.2 What the baseline-to-final numbers can and cannot prove

The original headline JMH methods returned semantic checksums. The corrected methods return the decoded object to the blackhole. The old TimeSeries checksum traversed its arrays inside the timed region.

Therefore, the campaign's original-to-final headline movement combines harness correction with runtime optimization and must not be quoted as a pure codec A/B. Source and version scope:

  • the final cross-format matrix for absolute ranking;
  • matched primitive-vector A/Bs for primitive claims;
  • matched string-scanner A/Bs for string claims; and
  • broad corpus runs for directional ranking, not one-percent deltas.

6.3 The 26-model corpus

The corpus covers nested objects, maps, lists, chat, configuration, telemetry, notifications, commerce, media, search, fitness, finance, graph snapshots, games, IoT, education, logistics, market data, and social graph deltas.

DirectionCompetitor/generated geometric meanGenerated wins
EncodeRaw Flex 2.151x26/26
EncodeJSON 2.128x26/26
EncodeProtoBuf 2.088x26/26
DecodeRaw Flex 4.245x26/26
DecodeJSON 9.199x26/26
DecodeProtoBuf 6.558x26/26

These corpus schedules are intentionally shorter: one fork, two warmups, and three 200 ms measurements. They establish the ranking. Changes around one percent are noise until repeated under a longer matched schedule.

6.4 Primitive-vector matched A/B

Representative 4,096-element JVM encode results:

VectorBaselineFinalSpeedup
Natural Short1.688 us0.132 us12.77x
Natural Int2.247 us0.241 us9.33x
Natural Long0.308 us0.306 us1.01x
Natural Float0.592 us0.281 us2.11x
Natural Double0.604 us0.415 us1.46x
Compact Short1.462 us0.132 us11.08x
Compact Float0.602 us0.254 us2.37x
Compact Double0.602 us0.262 us2.30x

Across all 20 encode cells, the point-estimate geometric speedup is 2.51x, with 18/20 wins. Decode/materialize improves 1.07x geometrically because the returned primitive arrays already dominate allocation.

6.5 Generated accessor evidence

Equivalent full-field accessor work in the expanded JVM report:

CaseGenerated accessorGeneric keyed accessGenerated advantage
UserProfile0.0748 us0.2922 us3.91x
ApiResponse0.5804 us2.5970 us4.47x
TimeSeries1.0074 us1.0320 useffectively tied

TimeSeries is tied because both paths spend most of the operation traversing the same numeric values. Do not compare these rows with the historical flexAccessorOnly method: that method intentionally reads fewer fields.

6.6 2026-07-13 re-verification and adversarial suite

The full JMH headline was re-run on 2026-07-13 under the identical schedule: allocation columns matched byte-for-byte and every within-run ratio held (absolutes drifted 5-15% with session thermals — exactly the cross-session variance §15 warns about). The pass added an 11-case adversarial + realistic suite (AdversarialBenchmarkModels.kt, bench/AdversarialBench.kt) that runs on JVM, Node, Android instrumentation, and the physical-iPhone bench app.

What it confirmed: generated decode won every cell on every platform — up to 9x over raw Flex on an 80-field map (binary search vs positional), 19x on a 96 KB metrics payload (boxed primitives vs bulk), and 96x over JSON on the same payload's JVM round trip.

What it caught that the 26-model corpus never exposed:

  • Non-ASCII string encode loses to kotlinx JSON everywhere (JVM 3.7x, V8 3.7x, ART 1.7x, iOS 1.6x slower): the flex path pays a UTF-8 length-precompute pass plus an encode pass, JSON streams once. On the S23 the length pre-scan alone measured equal to the entire encode pass.
  • iOS non-ASCII decode is the single measured decode loss (2.1x slower than JSON): per-string usePinned + NSString + UTF-16 conversion.
  • The Kotlin/JS builder's Long funnel is broader than the headline suggested: generated encode trails JSON by 2.6-4.1x on every object/string shape on V8.
  • Deliberate width-bombing (one Long.MAX_VALUE forcing 8-wide vectors) costs ~25% extra encode versus raw on JVM and produces smaller wire bytes — the price of optimal width selection, accepted.

Full data: reaktor-flexbuffer/PERFORMANCE_VERIFICATION_2026-07-13.md.

7. Platform-specific evidence and policy

7.1 JVM and Reaktor Server

JVM is the strongest measured target:

  • JIT inlines direct generated calls well;
  • HotSpot gives selective Unsafe.copyMemory wins;
  • compact-string access removes ASCII copies and scans;
  • caller-owned buffers materially reduce allocation; and
  • generated decode is consistently ahead of JSON and ProtoBuf.

For Reaktor Server and k3s services, the highest-value use is a binary internal contract at high-frequency, jointly deployed boundaries: actor mailboxes, graph snapshot replication, telemetry batches, or native compute calls. Public HTTP APIs should not change format merely because a microbenchmark is faster.

The current Ktor/Spring/service surfaces remain JSON unless a specific binary transport is explicitly negotiated.

7.2 Android/ART

The API 36 arm64 emulator instrumentation suite passed 64/64 tests. Values below are us/op. The scored debug instrumentation benchmark used a 10,000-operation unmeasured prime, followed by 500 warmups and three 5,000-operation runs; it reports the minimum of the three runs.

CaseGenerated encodeGenerated decodeGenerated RTSerializer RTRaw Flex RTJSON RT
UserProfile8.445.7514.1913.0320.5622.01
ChatThread24.248.7633.0032.4365.20117.89
ApiResponse62.4727.0489.5186.87158.54255.64
TimeSeries10.926.7917.7116.6551.55349.86

Both direct and serializer-routed generated round trips beat raw Flex and JSON in every case. Direct-versus-routed differences are benchmark noise at this resolution. JSON encode alone remains competitive or faster for some object/string-heavy shapes; generated decode recovers the total.

Do not infer physical-device behavior from this table. It does not include:

  • release/minified compilation;
  • phone thermals;
  • OEM ART changes;
  • big.LITTLE core placement; or
  • app-level contention.

Physical-device results (2026-07-13, Samsung S23 Ultra / SD 8 Gen 2 / Android 16, debug instrumentation APK, same steady-state schedule):

CaseGenerated encodeGenerated decodeGenerated RTRaw Flex RTJSON RT
UserProfile21.8116.3238.1357.0358.53
ChatThread63.1724.9988.17177.17302.90
ApiResponse170.2561.47231.73412.24630.72
TimeSeries26.4816.1642.64156.54979.23

Absolutes are 2.4-2.7x the emulator's; every ranking holds, including JSON's isolated encode win on UserProfile. On-device MicroBench puts ART string materialization at ~300-500 ns per string (~10x the JVM floor) — string decode dominates object-heavy payloads on Android.

The July physical bulk-transfer A/B (PrimitiveBulkAbBenchmark) informed a proposed kernel policy. It did not implement production kernels:

Width256-elem enc/dec4,096-elem enc/dec
Shortbulk 4x SLOWER2.4x / 2.7x faster
Int~parity19.1x / 17.7x
Long~parity12.1x / 11.7x
Float2.7x / 3.0x46.2x / 47.7x
Double2.7x / 2.8x31.0x / 32.0x

ART's scalar float/double path costs ~26 ns/element; java.nio little-endian typed views route to native bulk memcpy. Two more physical-ART facts: sun.misc.Unsafe.copyMemory(Object,...) does not exist on this device, and String.value/coder reflection fails (StringAccess.ON == false) — the JVM Latin-1 encode fast path silently never engages on Android, and the measured UTF-8 length pre-scan costs as much as the encode pass itself. A/B'ing the ART compact-string ISO_8859_1 constructor against decodeToString for short ASCII was neutral (0.88-1.08x) — that hypothesis is closed.

Proposed policy, not shipped: benchmark and implement ByteBuffer bulk kernels on Android for Int/Long/Float/Double at n >= ~64 (Float/Double also at n >= ~32); keep Short scalar below ~1,024. Adopt single-pass string encode (drop the length pre-scan) instead of porting the reflection fast path. Release-APK (testBuildType=release) instrumentation remains the one unmeasured Android configuration. The October A/B also improves large Short vectors; thresholds must be established per type and direction. PrimitiveArrayCopy.android.kt still returns false for every bulk actual.

7.3 Kotlin/Native and iOS

The release iosSimulatorArm64 executable ran eight 100,000-operation batches per cell; round one was discarded and rounds two through eight were medianed.

CaseGenerated encodeGenerated decode
UserProfile2.493 us1.165 us
ApiResponse22.129 us11.400 us
ChatThread8.225 us3.783 us
TimeSeries3.331 us0.276 us

TimeSeries decode is legitimately small because two pinned bulk copies fill the returned primitive arrays. Object/string-heavy payloads still pay Kotlin/Native allocation and string conversion.

Simulator results establish code-generation and allocator behavior on the host CPU. They do not establish device thermals or sustained mobile throughput.

Physical-device results (2026-07-13, iPhone 14 Pro / A16 / iOS 26.5, release iosArm64 bench executable in a signed app, identical methodology):

CaseGenerated encodeGenerated decodevs simulator
UserProfile3.016 us1.137 us1.21x / 0.98x
ChatThread10.612 us4.585 us1.29x / 1.21x
ApiResponse28.858 us14.797 us1.30x / 1.30x
TimeSeries3.831 us0.300 us1.15x / 1.09x

Rounds were thermally flat; wire bytes matched the audit exactly. The simulator matrix is therefore a reliable proxy at a 1.1-1.3x scale factor for this device class. The adversarial suite added one iOS-specific finding: on non-ASCII content, generated decode runs 2.1x slower than kotlinx JSON (the per-string pin + NSString + UTF-16 path) — the pin-once/CFString rework in the roadmap is what fixes it.

Native-specific operating rules:

  • pin for the shortest possible interval;
  • perform one bulk operation per vector, not a callback per element;
  • define buffer ownership explicitly at C boundaries;
  • free native-owned memory exactly once; and
  • do not retain a pointer into a Kotlin array after unpinning.

7.4 JavaScript, browser, Node, and Cloudflare Workers

The historical July production Kotlin/JS library ran on Node 22.18.0 / V8 12.4. The October Chrome measurements are actual browser evidence with different decode rankings and native-JSON baselines. The July Node cells below used a 2,000-operation warmup and seven 5,000-operation measured batches.

Case/directionGeneratedRaw FlexJSONProtoBuf
User encode14.94419.3324.90520.585
User decode2.3165.1057.50111.432
API encode82.516134.21151.164119.251
API decode10.57038.49155.95085.724
TimeSeries encode17.00820.47932.583191.388
TimeSeries decode4.02832.87791.812122.302

Generated decode wins every cell. The measured kotlinx JSON encoder wins string/object-heavy encode, although this campaign did not isolate the runtime mechanism responsible. Generated Flex wins numeric TimeSeries encode.

Wire sizes in bytes, ordered generated/raw Flex/JSON UTF-8/ProtoBuf:

  • User: 833 / 833 / 710 / 473
  • API: 7,111 / 7,489 / 8,770 / 5,822
  • TimeSeries: 4,340 / 4,340 / 5,807 / 4,157

Node is evidence about production Kotlin/JS on V8, not proof of identical Cloudflare Workers performance. A Worker deployment adds isolate startup, bindings, network copies, and platform-specific limits. Measure the actual Worker before setting a budget.

JavaScript-specific guidance:

  • use Uint8Array/ArrayBuffer boundaries rather than arrays of numbers;
  • avoid per-byte Kotlin loops when converting ByteArray;
  • transfer ArrayBuffer ownership between Web Workers when possible;
  • keep 32-bit offsets and metadata out of Kotlin Long;
  • use JSON encode where V8 already wins and binary decode/transport where it removes larger downstream costs; and
  • normalize Float through binary32 in cross-codec verification.

7.5 C++ comparison

The normalized adversarial harness compiles the vendored C++ reference with clang++ -O3 -DNDEBUG -mcpu=native. Kotlin and C++ consume byte-identical fixtures, read the same fields, and produce the same checksums.

CaseKotlinC++Kotlin/C++
Tiny key decode0.175 us0.071 us2.5x
Tiny index decode0.063 us0.011 us5.9x
Tiny three-key partial read0.175 us0.030 us5.8x
256 sparse misses2.945 us2.619 us1.1x
String table scan2.668 us0.923 us2.9x
TimeSeries numeric scan1.047 us1.010 us1.0x
64 random wide-map keys4.698 us4.406 us1.1x
64 sequential indexes0.133 us0.119 us1.1x
Unique-string encode, sharing64.117 us126.929 us0.5x
Unique-string encode, no sharing47.417 us57.810 us0.8x

C++ remains the tiny-read floor. Kotlin approaches parity on large numeric and wide-map access and wins the two measured unique-string builder cases.

Do not claim that Kotlin is universally faster than C++. The useful result is that crossing to C++ merely to parse a large FlexBuffer is unlikely to pay for itself; native work should be justified by a real compute kernel, platform API, or already-native producer/consumer.

8. FlatBuffers versus FlexBuffers

The C++ comparison builds semantically equivalent FlatBuffer and FlexBuffer representations for 13 fixtures. All partial and full checksums match.

  • FlatBuffers faster encode: 13/13, 6.33x geometric mean.
  • FlatBuffers faster partial access: 12/13, 1.17x geometric mean.
  • FlatBuffers faster full access: 12/13, 1.13x geometric mean.
  • FlatBuffers smaller as raw bytes: 8/13.
  • FlatBuffers smaller after an 8-byte schema/version id: 8/13.
  • FlatBuffers smaller after including the actual schema: 0/13.

This is not a failure of FlexBuffers. It describes the price of self-description. Charging the full FlatBuffer schema to every payload is an illustrative schema-inclusive distribution comparison, not normal production transport: shared schemas are usually deployed once and amortized across many payloads.

RequirementPrefer
Mandatory shared schema, maximum encode speedGenerated FlatBuffers
Self-describing data, dynamic inspection, schema-less native producerFlexBuffers
Public/debug/human-readable contractJSON
Stable compact schema contract with broad ecosystemProtoBuf or another negotiated schema format
Partial reads of trusted Reaktor payloadGenerated Flex accessor

9. BestBuds: real complex-model integration

BestBuds now has one JVM integration benchmark around a representative deep, production-hot messaging serialization graph:

MessagingResponse.Messages
  -> Chat
       -> EntityData
       -> 100 ChatMessage
            -> nullable reply fields
            -> List<MessageReaction>
            -> MessageKind
            -> ModerationStatus
            -> contextual Instant

The fixture contains:

  • 100 messages, representing five 20-message older-message pages;
  • 246 reactions;
  • 33 replies;
  • every message kind;
  • every moderation state;
  • nullable fields;
  • Unicode and emoji; and
  • the sealed service response envelope.

It uses the exact same MessagingResponse.serializer() for:

  • Reaktor's configured kotlinx JSON instance; and
  • FlexBuffers.encode(serializer, value) / FlexBuffers.decode(serializer, bytes).

The messaging models have no generated FlexCoder, so this is deliberately a raw fallback comparison, not a generated-tier showcase.

Median result from the passing full BestBuds JVM test suite:

OperationJSONRaw FlexInterpretation
Encode70.85 us89.00 usFlex is 25.6% slower; JSON latency is 20.4% lower
Decode147.00 us87.08 usFlex is 1.69x faster
Round trip213.08 us172.91 usFlex is 1.23x faster
Wire size57,441 B40,183 BFlex is 30.0% smaller

The test performs correctness round trips before timing, warms each path, alternates order, takes the median of five samples, writes to a volatile sink, and emits a machine-readable BESTBUDS_SERIALIZATION_PERF line. It is diagnostic, not a CI superiority gate.

BestBuds adoption opportunities

SurfaceCurrent statePotential benefitGate before adoption
Chat WebSocket framesExisting transport remains non-FlexSmaller frames and faster mobile decodeBinary negotiation, JS byte-copy removal, schema/version envelope
Whole-Chat SQLite cacheExisting cache model remains unchangedPotentially faster load and smaller BLOBReal FlexSerializer, fingerprint/keyed fallback, record migration, and accessor-aware storage APIs
Android/iOS native callsC++ hello proof existsOne byte-buffer crossing instead of many JNI/cinterop callsStable C ABI, ownership, typed errors
Notification batchesExisting service path remains unchangedBatch decode and fewer allocationsMeasure real payload; preserve external provider JSON
Telemetry/event batchesExisting ingest remains unchangedPrimitive-vector and batch winsBackpressure, sampling, schema id, server support
Media/search manifestsExisting API remains unchangedPartial accessor readsMeasure string-heavy encode; JSON may remain better

The WebSocket path is the strongest immediate product experiment:

  • the lower Reaktor Ktor and PartyServer abstractions already accept binary frames;
  • current BestBuds chat sends text JSON;
  • incoming text is trial-decoded as several possible frame models, so the same text may be parsed repeatedly; and
  • the Worker-side PartyServer currently allocates and loops over every byte when converting between Kotlin ByteArray and JavaScript typed arrays.

A useful experiment must first replace those byte-by-byte conversions with verified typed-array bulk transfer. Then use one tagged binary envelope so a receiver identifies the frame kind and decodes exactly once. Mobile and Worker versions require text/binary negotiation and JSON fallback during rollout.

The SQLite layer is structurally capable of choosing TEXT or BLOB by serializer type, but FlexSerializer is still unfinished. Whole-Chat cache migration therefore requires a real binary serializer, record/version metadata, mixed old/new reads, rollback tests, and fingerprint fallback before switching storage columns.

The immediate lesson is not “replace all BestBuds JSON.” It is:

  1. the raw binary fallback already improves the expensive decode/round-trip side;
  2. a supported flattened generated transport DTO may improve it further, but the current sealed/contextual model is not directly supported and needs its own benchmark;
  3. persistence must wait for schema negotiation; and
  4. browser/Worker conversion code must avoid per-byte copies or it can consume the codec win.

10. Manna: where FlexBuffers could matter

Manna currently has no production reaktor-flexbuffer call site. Its browser, Worker, GraphQL, and knowledge-graph surfaces remain JSON-oriented.

That makes the following recommendations opportunities, not shipped claims.

High-value candidate: knowledge-graph snapshot

Manna's checked-in generated BestBuds knowledge-graph source is 749,090 bytes: about 749 kB decimal or 732 KiB. The unified-graph path performs a GraphQL JSON read, Worker JSON.parse, transformation, JSON response, browser text/JSON parse, and full node/edge projection.

A browser-delivered binary companion with dense node, edge, count, and chapter data could:

  • reduce parse and materialization time;
  • keep numeric node/edge arrays contiguous;
  • let the UI read summary/header fields without expanding the entire graph;
  • transfer an ArrayBuffer into a Web Worker;
  • cache one binary artifact in R2 or browser storage; and
  • decode selected views through accessors.

JSON should remain the authoring/debug/export representation. A build step could produce the binary derivative and verify semantic checksums against JSON. Benchmark compressed transfer size and the complete fetch-to-mount path, not only codec time. If the browser immediately recreates the full JavaScript object graph, eager materialization may erase much of the accessor/parse win.

Boundaries that should remain JSON by default

  • public GraphQL results;
  • MCP tool responses intended for model/human inspection;
  • configuration edited by humans;
  • third-party APIs; and
  • low-frequency data where transport/parse cost is not material.

Use a negotiated binary side channel for graph snapshots or bulk arrays rather than forcing FlexBuffers through a text-first interface.

11. Reaktor-wide target map

Reaktor target or subsystemBest-fit FlexBuffer roleStatus
reaktorServer / k3s JVMInternal high-rate messages, snapshots, telemetry batchesCandidate; public/service routes remain JSON
Android app targetsFast binary decode, JNI payloads, offline cache after versioningCodec ready; product migration not done
iOS app targetscinterop payloads, native-produced vectors, fast decodeCodec and hello proof ready; typed protocol not done
Browser workbenchGraph snapshots, worker transfer, partial inspectionCandidate; JS encode tradeoffs require measurement
Cloudflare WorkersBinary service binding/DO/queue side channelCandidate; Node results are not Worker proof
reaktorDesktopLarge graph snapshots, traces, local tool/native resultsCandidate; current MCP/control paths remain JSON
Actors and mailboxesJointly deployed ephemeral envelopesStrong fit after fingerprint/version envelope
Bus/queues/workflowsHigh-throughput internal framesFit only with rolling-upgrade compatibility
ObjectStore/persistenceCompact BLOB; future accessor-aware readsBlocked on serializer, fingerprint, record migration, and storage/accessor API work
Telemetry/analyticsNumeric batches and event envelopesStrong workload fit; external observability protocols still apply
reaktor-ffiStable one-buffer call/result/error frameCore intended role; protocol still experimental
Reaktor Arena / Unreal/C++Native simulation state, commands, observationsFuture candidate; benchmark against schema-known FlatBuffers
MannaKnowledge-graph snapshot and worker transferOpportunity; no shipped integration
BestBudsMessaging, cache, native, telemetryRaw benchmark and hello proof only today
WebAssemblyLinear-memory offset/length buffersNo wasmJs or wasmWasi target exists today

12. High-performance FFI design

:::tip Full architecture FlexBuffer-side mechanics include (copies, ownership, envelopes, budgets). The complete FFI architecture — typed actor cells, switches and trunks, cross-language Flow with credit-based backpressure, the C ABI for React Native/Flutter/Python, and gRPC/kotlinx.rpc integration — is specified in Reaktor FFI — Switchboard. :::

12.1 Why byte protocols help FFI

FFI cost is rarely only the codec. It includes:

  • boundary transitions;
  • object conversion;
  • string conversion;
  • pinning;
  • native allocation;
  • copying;
  • callbacks;
  • exception translation; and
  • ownership bookkeeping.

A single byte envelope turns many per-field calls into one boundary crossing:

bad:
  JNI call(id)
  JNI call(name)
  JNI call(tags[0])
  JNI call(tags[1])
  ...

better:
  encode one request
  cross boundary once
  native reads only needed fields
  return one result/error buffer

FlexBuffers is useful here because C++, Kotlin, JavaScript, and future languages can inspect the same self-describing bytes without generating a schema-specific binding for every experimental call.

12.2 Current native proof

Today, NativeFlexBuffer.cpp builds:

{
  "message": "Hello from FlexBuffer C++",
  "answer": 42
}

Android:

  • builder.GetBuffer() is copied into the returned std::vector<uint8_t>;
  • JNI allocates a jbyteArray;
  • SetByteArrayRegion performs the second post-builder copy;
  • Kotlin decodes them.

iOS:

  • builder.GetBuffer() is copied into the returned std::vector<uint8_t>;
  • C++ copies that vector into a malloc-owned ReaktorByteBuffer;
  • cinterop readBytes copies it again into a Kotlin ByteArray;
  • Kotlin calls Reaktor_FreeByteBuffer in finally;
  • Kotlin decodes the copy.

This proves compatibility and ownership cleanup. It is not zero-copy and is not a typed RPC protocol.

The current copy ledger is explicit:

  • Android performs two semantic post-builder copies: builder vector to returned vector, then returned vector to jbyteArray.
  • Darwin performs three: builder vector to returned vector, vector to malloc, then readBytes into Kotlin memory.
  • Worker/WebSocket helpers currently allocate and perform per-byte loops in both directions.

Removing a bridge copy or byte loop may produce a larger end-to-end gain than another scalar-reader micro-optimization.

The separate reaktor-ffi module also contains an Android positional Flex payload and Hermes/JSI proof. Its typed argument<T>() path is unfinished, every ByteBufferTransport method is still TODO, its sync/async result model is a scalar prototype, and its module registry is mutable startup state. The transport is a nonfunctional skeleton around useful integration proofs, not a versioned bytes-in/bytes-out production runtime.

12.3 Required production envelope

A production FFI frame should include at least:

FieldPurpose
protocol magicReject unrelated/corrupt bytes quickly
protocol versionNegotiate envelope behavior
layout/schema fingerprintGate exact-layout access
module and operation idDispatch without string-heavy repeated lookup
request/correlation idMatch async responses and traces
frame kind and sequenceDistinguish request, result, next, complete, error, and cancel frames
deadline/cancellationStop native or asynchronous work coherently
flagsPayload semantics such as compression, streaming, and compatibility mode; never allocator ownership
payloadFlexBuffer body
error code/categoryStable error transport across language boundaries
optional trace contextConnect native timing to Reaktor telemetry

The fingerprint must cover serialized names, field types, nullability, nesting, relevant defaults, and positional order. On a mismatch:

  1. fall back to keyed compatible decode where supported;
  2. negotiate another protocol version; or
  3. fail with a typed compatibility error.

Never continue positional decode after a fingerprint mismatch.

12.4 Ownership contract

Every ABI function must answer:

  • who allocated the buffer?
  • who frees it?
  • may the receiver retain it?
  • is it mutable?
  • how long is it valid?
  • what alignment is guaranteed?
  • can the call return an empty buffer?
  • how is allocation failure represented?

A practical C ABI keeps borrowed request memory and owned result memory as different types. ABI compatibility is negotiated outside the payload:

typedef struct {
  const uint8_t* data;
  size_t size;
} ReaktorBytes;

typedef struct {
  const uint8_t* data;
  size_t size;
  uint64_t handle;
} ReaktorOwnedBuffer;

uint32_t reaktor_ffi_abi_version(void);

ReaktorStatus reaktor_call(
  ReaktorBytes request,
  ReaktorOwnedBuffer* response
);

void reaktor_buffer_release(uint64_t handle);

The exact ABI is still a design decision. Before non-test calls, the caller must check the ABI version or capability surface; the protocol version inside the bytes cannot protect against a mismatched C struct or exported function.

Keep three independent compatibility values: the C ABI version, the byte protocol version, and the generated schema/layout fingerprint. A match at one layer says nothing about the other two.

The contract must additionally define:

  • the zero-initialized response state;
  • response contents after every failure status;
  • whether an empty result has a null pointer, zero size, or both;
  • whether releasing an unknown or already released handle is idempotent or an error;
  • whether release is thread-affine; and
  • whether a successful result may be retained across later calls.

Allocator ownership is an out-of-band ABI property. Untrusted wire flags must never decide whether or how native memory is freed.

12.5 Platform boundary guidance

JNI / Android

  • Cross once per logical request, never once per field.
  • Prefer a direct buffer or an explicitly measured jbyteArray copy depending on payload size and lifetime.
  • Cache class/method IDs outside the hot call.
  • Do not retain a direct pointer after the Java/Kotlin owner can move or release it.
  • Convert errors into stable status codes plus an optional FlexBuffer detail, not arbitrary C++ exceptions through JNI.
  • Benchmark ART on physical release devices.

cinterop / Apple

  • Use a stable C ABI even when the implementation is C++ or Swift.
  • Pin Kotlin memory only for the duration of a synchronous native read.
  • For native-owned output, copy once or wrap with a lifetime object that always calls the paired release function.
  • Avoid Objective-C dictionaries and per-field bridging in the hot path.
  • Keep autorelease/NSString work outside numeric kernels.

No general Swift-facing typed invocation API exists today. A future Swift/ObjC wrapper may copy into Data, or use Data(bytesNoCopy:) only when an owning object retains the native handle and supplies the exact release callback. Every lazy accessor or view must retain that owner; otherwise a nominal zero-copy path becomes use-after-free.

JSI / Hermes and browser JavaScript

  • Expose ArrayBuffer/Uint8Array, not JavaScript arrays of boxed numbers.
  • Avoid byte-by-byte Kotlin conversion helpers.
  • Use external/host buffers only with a proven lifetime contract.
  • Keep offsets and lengths as JavaScript-safe 32-bit numbers when the protocol allows it.
  • In browser Web Workers, transfer an ArrayBuffer rather than clone it when ownership can move; transfer detaches the sender's buffer. This does not describe Cloudflare Worker isolates or Hermes/JSI generally.
  • Avoid recursively converting an entire FlexBuffer into JSI arrays and objects; expose an immutable buffer/HostObject or convert only demanded fields.

JVM native/server libraries

  • A future adapter may expose builder storage to ByteBuffer or a native memory segment; no such universal ReadBuffer adapter exists today. Implement and measure that adapter before claiming a zero-copy JVM handoff.
  • Once such an adapter exists, avoid a copy merely to satisfy a layer that can consume the original slice.
  • Do not expose pooled mutable storage beyond its lifetime.

C++ / Unreal / native engines

  • Use accessors for commands or observations that read only selected fields.
  • Batch world/entity updates instead of issuing one bridge call per entity.
  • If the schema is mandatory and stable, compare generated FlatBuffers before choosing FlexBuffers.
  • Keep the Kotlin graph/runtime authoritative unless profiling identifies a specific native compute kernel.

WebAssembly

No Reaktor Wasm target exists today; the current web target is Kotlin/JS IR. When a concrete browser or WASI consumer justifies one, use linear-memory offset/length pairs and typed-array views. A view becomes invalid after memory growth or allocator reuse, so the ABI needs the same explicit ownership and lifetime rules as native pointers. Do not add Wasm solely to claim another target.

12.6 FFI performance budget

Measure the complete boundary:

encode
+ output copy
+ bridge transition
+ input copy/wrap
+ validation
+ native work
+ result encode
+ return copy/wrap
+ decode or accessor reads

Useful metrics:

  • total call latency and p50/p95/p99;
  • encode/decode/accessor latency separately;
  • bytes and copies per call;
  • allocations per side;
  • boundary transitions per logical operation;
  • validation/fingerprint misses;
  • fallback-decode rate;
  • native-owned bytes outstanding;
  • pool misses and retained builder capacity; and
  • result/error ratio.

A codec microbenchmark is necessary, but an FFI decision must use end-to-end numbers.

13. Wire, compatibility, and security rules

Signed integer upgrade

The encoder now writes negative integers at C++-compatible minimal signed widths. New readers decode old wide-negative buffers. Old Reaktor readers may misread new narrow negatives because they zero-extended T_INT.

Rolling upgrade order:

  1. deploy new readers;
  2. verify old payloads;
  3. deploy new writers;
  4. retire old readers; and
  5. optionally compact/re-encode persisted data afterward.

Existing persisted buffers do not need an eager rewrite merely to be read by the new reader.

Trusted versus untrusted bytes

The following table is the required boundary contract. Current empty-map validator, null/default and generated layout defects must be fixed before claiming the contract is satisfied. Raw/keyed decoding is not a blanket correctness guarantee.

BoundaryRequired behavior
External network, file, user inputValidate structure; remain on raw/keyed decode unless exact layout is independently proven
Internal buffer after structural validationGenerated path only after fingerprint match or a version-locked out-of-band layout contract
Persistence across versionsCheck protocol and layout fingerprint
FFI result from third-party/native pluginTreat as untrusted until validated
Same-process jointly compiled generated callExact-layout path acceptable

The JVM/Android memory layer intentionally uses unchecked direct loads where available. Structural validation and schema/layout trust are therefore separate, mandatory parts of the API architecture.

Remaining format-parity blockers

The October reproducible failures are immediate blockers: equal buffer-backed maps must hash equally; valid empty maps must validate; explicit null must not become a default; missing required fields and incompatible layouts must reject instead of reading the wrong field. These defects remain unfixed by this documentation refresh.

  • full public support for fixed typed vectors;
  • deprecated string-vector compatibility;
  • additional upstream indirect scalar, reuse, mutation, verifier, Unicode, and golden-binary tests;
  • complete KSP handling or compile-time rejection for @Transient, defaults, unsigned fields, and unsupported nested collection/map shapes; and
  • a schema fingerprint with compatible keyed fallback.

Additional implementation risks that must remain visible:

  • unsupported KSP shapes still reach generated runtime TODO/error branches in some cases instead of failing compilation;
  • the raw decoder's shared field-index cache uses unsynchronized cold writes and should be prewarmed or made concurrency-safe before heavily concurrent first use;
  • registry writes and clears are startup-only and must not overlap reads;
  • pooled builders may retain a giant backing array after an exceptional payload;
  • generated access depends on both complete structural ingress validation and independent exact-layout proof; and
  • no end-to-end production FFI latency/allocation benchmark exists yet.

14. Adoption playbook

Phase 0: prove the workload

  1. Capture a representative production fixture.
  2. Separate encode, decode, round trip, partial read, and size.
  3. Compare JSON, raw Flex, generated Flex, and a schema-known codec where relevant.
  4. Verify semantic equality before timing.
  5. Measure the actual target runtime.

Phase 1: add a versioned DTO

  • Keep domain models independent from wire-layout promises.
  • Create a transport DTO whose serialized names are deliberate.
  • Add @Struct only to shapes supported by the generator.
  • Configure the module registrar.
  • Test class/property @SerialName, nulls, collections, Unicode, and numerical edge values.

Phase 2: deploy generated bytes on an ephemeral internal path

  • Start with a jointly deployed producer/consumer.
  • Add protocol version and fingerprint.
  • Validate structure at ingress and check the fingerprint before generated positional access.
  • Keep a keyed or JSON fallback during rollout.
  • Instrument fallback and decode failures.

Phase 3: remove copies

  • Use a caller-owned builder only after profiling shows the final copy matters.
  • Pass the view directly to the next synchronous consumer.
  • Make builder lifetime explicit in code review.
  • For JS/FFI, fix any byte-by-byte conversion before claiming a win.

Phase 4: consider persistence

Do this only after:

  • layout fingerprinting;
  • reader-first upgrade support;
  • migration fixtures across released schemas;
  • corrupt/truncated input testing;
  • rollback behavior; and
  • operational tooling to inspect or convert stored blobs.

15. Benchmark discipline

Every scored setup should:

  • verify equality or a semantic checksum before timing;
  • keep checksum traversal outside the timed region;
  • return the decoded value to a blackhole;
  • clear and assert the generated registry for raw-fallback measurements;
  • warm each implementation and direction independently;
  • alternate or randomize paired ordering;
  • report allocation as well as time;
  • use UTF-8 byte length for JSON wire size;
  • distinguish debug/emulator/simulator from release physical hardware;
  • run scored suites sequentially on a cool machine; and
  • retain machine-readable artifacts.

The October campaign now retains machine-readable JMH, cross-platform, Chrome, bulk-transfer, wire-size and profiling evidence in the published ledger. Its median and adversarial harnesses still have less rigor than JMH. The following reproducibility gaps refer to the historical July campaign.

Some July evidence is less durable than the JVM JMH artifacts. The final Node table, the JS pre/post range-check comparison, the Native score table, and the ART string-scanner diagnostic are recorded in the performance audit rather than separate machine-readable result files. The Native timing runner relies on the separate cross-platform suites for correctness rather than asserting equality inside every timed cell. Treat this as a reproducibility gap to close, not as a reason to discard the current ranking.

Avoid:

  • timing setup or fixture generation;
  • mixing partial reads with full materialization in one score;
  • comparing C++ and Kotlin on different work;
  • reporting the best single noisy iteration;
  • interpreting a one-fork one-percent movement as a regression;
  • benchmarking an Android case before ART finishes background compilation; and
  • running JMH forks while Gradle, simulators, or native linkers contend for the same machine.

16. Reproduction and evidence

From /Users/ovd/dev/reaktor:

# Compiler/code-generation correctness
./gradlew :reaktor-compiler:test

# Cross-platform codec correctness
./gradlew :reaktor-flexbuffer:jvmTest
./gradlew :reaktor-flexbuffer:jsNodeTest
./gradlew :reaktor-flexbuffer:iosSimulatorArm64Test
./gradlew :reaktor-flexbuffer:testDebugUnitTest

# Build and run configured JVM JMH
./gradlew :reaktor-flexbuffer:jvmBenchmarkBenchmarkJar
./gradlew :reaktor-flexbuffer:jvmBenchmarkAllocationStats

# Android/ART connected target
./gradlew :reaktor-flexbuffer:connectedDebugAndroidTest

# Production Kotlin/JS runner
./gradlew :reaktor-flexbuffer:compileProductionLibraryKotlinJs
REAKTOR_JS_BENCH_WARMUP_ITERATIONS=2000 \
REAKTOR_JS_BENCH_ITERATIONS=5000 \
REAKTOR_JS_BENCH_WARMUP_ROUNDS=1 \
REAKTOR_JS_BENCH_ROUNDS=7 \
node --input-type=module -e \
  "import('./reaktor-flexbuffer/build/compileSync/js/main/productionLibrary/kotlin/reaktor-reaktor-flexbuffer.mjs').then(m => m.runJsFlameChart())"

# C++ semantic verification and scored run
clang++ -O3 -DNDEBUG -mcpu=native -std=c++17 \
  -I .github_modules/flatbuffers/include \
  reaktor-flexbuffer/cpp/bench/flatbuffers_vs_flexbuffers.cpp \
  -o reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers

reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers --verify-only
reaktor-flexbuffer/build/tmp/flatbuffers_vs_flexbuffers \
  --warmup 1000 --iters 10000 --runs 7

Current review evidence: reaktor-context/flexbuffer-review-2026-10-03/, with a module mirror in reaktor-flexbuffer/PERFORMANCE_REVIEW_2026-10-03.md and the published JSON ledger. The October harness commands and device packaging belong to the retained review overlay; the commands above describe the module's standard benchmark entry points.

Historical July local evidence:

reaktor-flexbuffer/PERFORMANCE_AUDIT.md
reaktor-flexbuffer/build/reports/benchmarks/reaktor-flexbuffer-jmh-baseline.json
reaktor-flexbuffer/build/reports/benchmarks/full-2026-07-11/
reaktor-flexbuffer/src/jvmBenchmark/
reaktor-flexbuffer/src/commonTest/
reaktor-flexbuffer/src/jvmTest/
reaktor-flexbuffer/cpp/bench/

BestBuds integration:

cd /Users/ovd/dev/bestbuds
./gradlew :app:jvmTest --no-daemon --console=plain

The machine-readable BestBuds line appears in:

modules/app/build/test-results/jvmTest/
  TEST-ai.bestbuds.app.SerializationPerformanceIntegrationTest.xml

17. Remaining roadmap

Refreshed 4 October. The current review plan owns the measured priorities and qualifications:

  1. Fix all four reproduced correctness defects, cold cache concurrency and pooled retention. Add explicit wire/model regressions and reject unsupported KSP shapes.
  2. Generate fingerprints, required/default/null semantics and keyed fallback before rolling upgrades, persistence or independently deployed FFI. Generate count/element traversal to avoid repeated view wrappers. Use a compiler plugin where KSP cannot implement the required specialization, with incremental-build and size checks.
  3. Implement physical-device-backed Android ByteBuffer kernels; measure type/direction thresholds and release-APK end-to-end behavior. Keep compact widths and fallback.
  4. Address JS object encode with 32-bit metadata and typed-array boundaries; measure single-pass UTF-8 and pin-once/native string work rather than assuming a win.
  5. Integrate explicit buffer ownership with the FFI frame design, then benchmark actual loopback/JNI/cinterop/JSI hops, queueing, cancellation and tails.
  6. Benchmark C++/SIMD for bounded large batches or scan/filter kernels, including conversion, crossing and lifetime costs. Partial native reads are not full decode.
  7. Prototype the book's mutable-memory design separately from immutable FlexBuffers wire snapshots. Establish traversal, mutation, footprint and concurrency baselines before adopting cells/slabs/handles.
  8. Run ephemeral BestBuds DTO and Manna snapshot experiments only after the correctness gate. Keep fallback/evolution, real consumer semantics and actual target measurements.
  9. Complete upstream parity and benchmark packaging cleanup; cap retention and measure sharing, generated code size, cold startup, allocations and p95/p99 alongside speed.

The July speculative optimization queue remains in the historical module audit; neither it nor this roadmap is a claim that the proposed work is implemented.

18. Source map

ConcernPrimary source
Public encode/decode APIsreaktor-flexbuffer/.../core/FlexBuffers.kt
Coder contract and registryreaktor-flexbuffer/.../core/FlexCoder.kt
Serializer fallbackreaktor-flexbuffer/.../core/FlexEncoderV2.kt, FlexDecoderV2.kt
Builder and key blocksreaktor-flexbuffer/.../flatbuffers/FlexBuffersBuilder.kt
Reader viewsreaktor-flexbuffer/.../flatbuffers/FlexBuffers.kt
Generated direct readsreaktor-flexbuffer/.../flatbuffers/FlexRead.kt
Primitive transferPrimitiveArrayCopy.kt plus platform actuals
UTF-8 specializationFastDecode.kt plus platform actuals
Builder poolcore/FlexBufferPool.kt
Encoder/decoder poolcore/PerPlatformPool.kt plus platform actuals
Primitive-backed listscore/FlexCollections.kt
KSP generationreaktor-compiler/.../FlexCoderProcessor.kt
JVM JMHsrc/jvmBenchmark/...
Cross-platform harnesssrc/commonTest/.../CrossPlatformBenchmark.kt
C++ comparisoncpp/bench/
Native hello proofcpp/common/NativeFlexBuffer.*, Darwin/Android bridges
BestBuds integrationmodules/app/src/jvmTest/.../SerializationPerformanceIntegrationTest.kt

19. Rules to keep

  1. Generated coders are the fast tier; raw serializer Flex is the coverage tier.
  2. Register coders once during single-threaded startup.
  3. Validate untrusted structure and independently prove exact layout before unchecked generated access.
  4. Keep exact-layout payloads ephemeral until fingerprint fallback exists.
  5. Use accessors when the consumer needs only part of the object.
  6. Use caller-owned builders only with an explicit lifetime.
  7. Cross an FFI once per logical operation, not once per field.
  8. Keep JSON for public, human, and browser-native string/object use cases where it remains the better engineering choice.
  9. Benchmark FlatBuffers when a schema is already mandatory.
  10. Make every performance claim target-, direction-, workload-, and methodology-specific.