Field Notes/Compiler/Dispatch 024
APR 22 · 2026·14 MIN READ·DISPATCH 024

Lowering Compose to KBC: how composables survive the trip.

Preserving the Compose compiler's invariants, slot tables, group keys and remember semantics inside a portable bytecode is harder than it looks. Here's what broke, what we kept, and what we'll never ship.

Anya VoronovaCompiler lead · KetoyVM
FIG 00 · KETOY ISLAND · APR 2026 · CLOUD COVER 40%

The Compose compiler plugin is a small, strict set of rules on top of Kotlin. It rewrites @Composable functions into something that looks nothing like what you wrote: every call receives an implicit $composer parameter, every block gets a group key, every remember allocates a slot in a persistent slot table. The rules are load-bearing. Violate one of them and recomposition silently skips the wrong subtree.

When we started designing KBC, Ketoy Bytecode, the portable instruction set we ship inside every .ktx bundle, we had to decide, early, what layer of Compose we were going to serialize. Source? Kotlin IR, pre-Compose? Kotlin IR, post-Compose? JVM bytecode? Each option traded a different set of invariants.

This is the story of how we settled on post-Compose IR, why it was both obvious and a mistake, and the two months we spent fixing it.

§ 01 — STARTING POINTFour layers, none of them good

Here is the part of the Kotlin toolchain we cared about, in order:

Serializing source was a non-starter, we do not want to ship a compiler frontend on every device. Serializing FIR means bringing in the entire resolution environment. Pre-Compose IR was appealing until we realized we would have to re-run the Compose plugin on device, which meant shipping the Compose plugin itself inside the runtime. Viable, but fragile: every Compose release becomes a runtime release.

That left post-Compose IR. The rewrites are already done. We just need to faithfully serialize a tree we know how to execute.

The trouble with using post-Compose IR is that we inherited every design choice the Compose plugin ever made, including the ones made assuming the JVM.
The reason this post exists

§ 02 — THE PIPELINEFrom .kt to .ktx, end to end

01Source
02FIR
03IR pre
04IR post-Compose
05KBC lowering
06.ktx bundle

Our work lives entirely in step 05 and is driven by a small Gradle plugin that attaches after the Compose plugin has run. The output is a binary KBC file, wrapped in a signed container with a manifest, a resource table, and a forward-compatibility header.

What KBC actually is

KBC is a register-based, strongly-typed instruction set. Every function carries an explicit register count; every instruction references registers by index. We considered stack bytecode (shorter opcodes, simpler verifier) and rejected it, too many memory roundtrips in Compose-heavy code, where functions have a dozen implicit parameters.

Counter.ktx · disassembledKBC
; @Composable fun Counter(initial: Int = 0)
; regs = 8, implicit: $composer, $changed

FN Counter(r0: Composer, r1: Int, r2: Int, r3: Int) {
  group.start   r0, key=0x7aef12     ; composable group key
  slot.load    r4, r0, slot=0       ; remember { mutableStateOf }
  invoke.virt  r5, r4, "getValue"
  call         Text, r0, r5, r3      ; Text(count.toString())
  group.end     r0
  ret
}

Two things are worth noting. First, every composable call takes the composer in r0, we made this the ABI, so verifier checks are cheap. Second, group.start and group.end are first-class instructions, not library calls. The verifier can statically prove every group is closed. That alone eliminates a class of bugs we used to only catch at runtime.

NOTE

Group keys are stable across bundles. We derive them from a hash of the enclosing function's fully-qualified name plus the call's source position. Shipping a new bundle with an added composable at the top of a file does not renumber every key below it, which means recomposition state survives bundle updates.

§ 03 — THE NUMBERSSize, speed, verifier throughput

Here is where we landed after the rewrite. Baselines are measured on a Pixel 6, Android 15, one medium composable tree (the Monzo checkout flow, a representative bundle of 142 composables and 38 ViewModels).

TABLE 01 · BENCHMARKS · PIXEL 6 · AOSP 15N = 1,000 RUNS
MetricJVM class filesKBC v0.3KBC v0.2Δ
Bundle size (gzip)612 KB284 KB218 KB−64%
Parse + verify168 ms44 ms21 ms−87%
Link time91 ms38 ms18 ms−80%
First frame910 ms260 ms142 ms−84%
Recomposition / frame4.1 ms2.2 ms1.8 ms−56%
Steady-state memory34 MB22 MB19 MB−44%

The size win is unsurprising: JVM class files are designed for an environment we are not in. The parse+verify win is more interesting, our verifier is 4× faster than ART's not because we are cleverer, but because we designed our verifier and our instruction set together. Every invariant we need to check is expressible as a single forward pass.

The one we are least proud of

Recomposition time is still 1.8 ms per frame in the worst case we measure, which is fine, but we have not closed the gap with a native build (about 1.4 ms on the same device). Most of the overhead is in the slot-table implementation, which we wrote in plain Kotlin and have not yet JIT-compiled. That is the job for v0.5.

FIG 02Flame graph of one recompose frame · 142 composables · Pixel 6 · APR 2026

§ 04 — WHAT BROKEThree invariants we nearly lost

Three Compose invariants caused us real trouble. I will list them, what we shipped, and, for one of them, what we still owe you.

  1. Stability inference. The Compose plugin decides, at compile time, which parameters are stable and therefore skippable. We ship those annotations in KBC metadata. We do not re-infer on device.
  2. Remember semantics. remember { } is a slot-table allocation keyed by enclosing group. Our slot table implementation must match the Compose runtime's layout byte-for-byte or recomposition breaks across updates. It does. We have a conformance test suite with 2,400 cases.
  3. Live edit & hot reload. We do not support this yet. The Compose compiler has a live-edit mode that changes the slot-table layout slightly; we fail closed and require a full bundle swap. This is fine for production, painful in development. On the roadmap for v0.6.
KNOWN ISSUE

If you import a library that uses @NonRestartableComposable, KetoyVM 0.2 will correctly mark the function as non-restartable in metadata, but our optimizer does not yet honor the annotation during inlining. Tracked as KET-0412. Workaround: -Xketoy-no-inline-nonrestartable.

§ 05 — WHAT WE WILL NEVER SHIPThe long list, shortened

Every runtime is a stack of decisions about what to exclude. For KetoyVM, the list is long. A partial version:

These are hard rules. They keep the attack surface small, the verifier fast, and the mental model clean. If you want a language runtime that does all of the above, you already have one, it is called the JVM, and your phone is already running it.


The compiler is the hardest thing we will ever ship, and the least thing our users will ever see. If any of this was interesting, the KBC specification is on the changelog and there is an office hours session next Thursday, I will be there, and so will the person who wrote the slot-table port.

— A.V. · from the island, Apr 22 2026