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:
- Source — the
.ktfiles your engineers write. - FIR — the frontend's resolved, type-checked tree.
- IR (pre-Compose) — the same program, lowered to Kotlin IR, but with
@Composablestill being a marker annotation. - IR (post-Compose) — what the Compose plugin leaves behind: every composable rewritten,
$composerthreaded through, group keys inserted.
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
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.
; @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.
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).
| Metric | JVM class files | KBC v0.3 | KBC v0.2 | Δ |
|---|---|---|---|---|
| Bundle size (gzip) | 612 KB | 284 KB | 218 KB | −64% |
| Parse + verify | 168 ms | 44 ms | 21 ms | −87% |
| Link time | 91 ms | 38 ms | 18 ms | −80% |
| First frame | 910 ms | 260 ms | 142 ms | −84% |
| Recomposition / frame | 4.1 ms | 2.2 ms | 1.8 ms | −56% |
| Steady-state memory | 34 MB | 22 MB | 19 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.
§ 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.
- 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.
- 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. - 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.
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:
- No reflection. KBC has no
kotlin.reflect. If you need a class name at runtime, declare it. The verifier enforces this and the bundle will not load if it slips through. - No
java.lang.reflecteither. The bridge to host code goes through a typed, declared interface surface. You cannot reach across. - No dynamic class loading from the bundle. The bundle is sealed at publish time. A bundle cannot load another bundle.
- No JNI. If you need native code, it lives in the host.
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.
Anya Voronova
Compiler lead at KetoyVM. Previously JetBrains, previously LLVM. Writes one Field Note a month, usually about IR.