::Product·Development·React Native

How a Kotlin compiler plugin cut Android time to first render by 30%

A new Kotlin compiler plugin in SDK 56 strips reflection from Expo Modules on Android: 70% faster init, no code changes for app developers.

Łukasz Kosmaty

Łukasz Kosmaty

Engineering

How a Kotlin compiler plugin cut Android time to first render by 30%

Expo SDK 56 ships a Kotlin compiler plugin that removes reflection from Expo Modules on Android. The numbers: 70% faster module initialization, a 30% cut in time to first render, and Record conversions that run about 6x faster than in SDK 55.

If you're building an app, you get those gains automatically - the plugin runs during compilation, with no code changes on your side. If you maintain a module, the Record speedup is one annotation away.

This post covers how we got here, and why this particular approach worked when others didn't. For the Apple side, where Swift now talks to JSI directly, see the companion post Talking to JSI in Swift.

Reflection and the history behind it

Before Expo Modules existed, we had Unimodules. They worked a lot like the old React Native bridge modules: you'd scatter annotations across methods that you wanted to expose, and the runtime would discover everything through reflection.

class ClipboardModule(context: Context) : ExportedModule(context) {
override fun getName() = "ExpoClipboard"
@ExpoMethod
fun getStringAsync(promise: Promise) {
val clip = clipboardManager.primaryClip?.getItemAt(0)
promise.resolve(clip?.text?.toString() ?: "")
}
@ExpoMethod
fun setStringAsync(content: String, promise: Promise) {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, content))
promise.resolve(true)
}
}

If you need metadata about your own code, reflection is the obvious tool. What methods does a module export? What arguments do they take? Just ask the JVM. But reflection has a cost, and on Android that cost lands directly on startup time. Every module the runtime introspects adds milliseconds before the user sees anything.

When we started building the Expo Modules API we have today, we wanted two things: better ergonomics and less reflection. The Kotlin DSL was the easy win because it gave us the ergonomics and removed most of the reflection in one move. What it couldn't remove was the type information for function arguments and Record properties. Resolving those still meant runtime reflection - concretely, a typeOf<T>() call and the metadata lookups behind it - and that was the cost we couldn't fix with the DSL alone.

The real cost of reflection

That remaining cost comes in two parts. The first is reconstructing type parameters. The DSL reads argument and return types through typeOf<T>(), which works because T is reified. Normally the JVM erases generics, so at runtime you can't ask what T actually was - the information is gone by the time the code runs. Reified type parameters get around that, letting us read the concrete type. It works because typeOf lives in an inline function: the compiler copies it into each call site and substitutes the real type in directly. Retrieving type information this way is cheap in most cases, but it adds up when a module has many functions or deeply nested generics.

The second, and the heavier one, is Record conversion. A Record is our typed representation of a JS object on the native side. To convert one, we have to discover its shape at runtime: which properties it declares, which are exposed to JS, and what type each one has.

The cost of that discovery is high because it involves multiple layers of reflection. You have to ask the JVM for the class's memberProperties, then ask each property for its annotations and type, then make the field accessible to write to it. Also, not all of that information is directly available in bytecode. The JVM knows about classes and their members, but it doesn't know about Kotlin's type system. The Kotlin reflection library has to reconstruct that information by parsing the @Metadata annotation, which is a binary blob that the compiler generates.

Some of this we could sidestep. Top-level nullability, for example, doesn't need full reflection - with a reified T, a simple null is T check answers it. The nested cases (like the T in List<T>) are a different story. The JVM erases generics, so the type arguments are gone from the bytecode at runtime, and it has no notion of Kotlin nullability either. The only place that information still exists is the @Metadata annotation - and there's no shortcut to reading it. You're forced into parsing that metadata, which is exactly the cost we were trying to avoid.

Why we didn't go with code generation

The standard fix for this kind of problem is code generation, and both Java and Kotlin have well-known tooling for it. Annotation processors (kapt) and the Kotlin Symbol Processing API (KSP) run at build time and can emit source files that pre-compute all the type metadata, so you never touch reflection at runtime. We also considered standalone codegen tools that run before compilation, like the one React Native uses for its TurboModules.

We looked into it and didn't like what we saw. The first problem is that generated code becomes part of your project. It shows up in call stacks, you step through it in the debugger, and when something breaks in the bridge between JS and native, what you end up reading is machine output that nobody enjoys debugging. The second is that kapt and KSP can only add new files, never modify existing ones. Instead of augmenting a Record class in place, you'd generate a whole parallel class from scratch. A standalone tool just trades those problems for others: another step in the build, more integration with the toolchain, another thing to maintain.

So for a while, we were stuck. We lived with the reflection cost and kept an eye out for something better.

What changed with K2

Then Kotlin 2.0 landed with the new K2 compiler, and it changed what was possible. The add-only limitation of kapt and KSP is exactly what K2 lifts: the new compiler plugin API gives you access to the intermediate representation (IR) that the compiler produces. You're editing code as the compiler sees it, before it gets lowered to bytecode. If you produce something invalid, the compiler catches it, and you can write tests against the transformed IR. Unlike codegen, the result isn't a parallel layer of code you have to live with - it's small, surgical substitutions in well-defined places.

We had always known we could modify bytecode directly, but we never wanted to maintain that. Too fragile, too easy to produce something that only breaks at runtime on one specific Android version. The compiler plugin API gives the same power with an actual safety net.

What the plugin does

The idea behind the plugin is simple: everything reflection discovers at runtime, the compiler already knew at build time. The plugin, built on the K2 API, acts on that and goes after the two most expensive operations we just walked through:

1. Baked-in type descriptors

Whenever an Expo Module needs type information, it calls typeDescriptorOf<T>(). The function itself is a stub that throws if it ever actually runs:

fun <T> typeDescriptorOf(): PTypeDescriptor =
throw NotImplementedError(
"typeDescriptorOf<T>() should be replaced by the compiler plugin"
)

It exists so the code compiles, but it should never run. During compilation, the plugin intercepts every call to typeDescriptorOf<T>() and swaps it out for a direct reference to a pre-computed type descriptor object:

// What you write:
typeDescriptorOf<List<Int>>()
// The equivalent of what the compiler emits:
PTypeDescriptorRegistry.getOrCreateParameterized(
List::class.java,
isNullable = false,
parameters = arrayOf(
PTypeDescriptorRegistry.getOrCreateConcrete(
Int::class.java,
isNullable = false
)
)
)

You can think of typeDescriptorOf<T>() as our own, leaner version of typeOf<T>(). Both return an object that describes a type, but where typeOf returns a full KType, ours returns a PTypeDescriptor (the P is for Pika, the plugin's internal codename) that carries only what we actually use: a Class<?> reference, a nullability flag, and a list of parameter descriptors - with no dependency on the Kotlin reflection library.

The lean shape also keeps allocation down. For simple types like String or Int, the registry returns a pre-allocated static field, so there's no allocation at all. For parameterized generics, descriptors are cached and deduplicated across modules, so the cost is paid once. In a JVM microbenchmark, building a descriptor this way is roughly 2x faster than typeOf for complex types like Map<String, List<Int?>>.

2. Baked-in Record metadata

The fix for the reflection-heavy conversion we described earlier is a single annotation. Mark a Record with @OptimizedRecord and the plugin takes over:

@OptimizedRecord
class UserRecord : Record {
@Field val name: String = ""
@Field val age: Int = 0
@Field val address: AddressRecord? = null
}

That annotation is the opt-in. For any class marked with @OptimizedRecord, the plugin does at compile time exactly what SDK 55 did at startup: it reads the property names, types, and annotations and bakes them straight into the bytecode as plain objects, paired with direct accessors that use simple index-based dispatch. Setting a field goes from "make it accessible via reflection, then set it" to a plain assignment.

If that compiled metadata is present, the runtime takes the fast path. If it isn't - the annotation was left off, or the plugin didn't run - it falls back to the same reflection-based conversion as SDK 55. Either way, your module keeps working.

Like Records, Jetpack Compose props marked with @OptimizedComposeProps get the same treatment, applied to prop resolution instead of field conversion. That matters, because prop resolution was one of the biggest bottlenecks for packages like expo-ui that lean heavily on Android's declarative UI.

Performance

How much you gain depends on how many Expo Modules your app uses and what types they export. We measured cold starts of a module-heavy test app - all official Expo modules plus the most popular third-party TurboModules - on two devices, a OnePlus 9 Pro and an older Samsung Galaxy S9:

  • Android module initialization is about 70% faster
  • Time to first render improved by roughly 30%
  • Record conversion is around 6x faster

The raw cold-start numbers from one of our module-heavy test app (clean mean over 150 iterations, outliers removed):

MetricSDK 55SDK 56Change
Cold launch (Activity.onCreate)93 ms55 ms-41%
Time to first render797 ms508 ms-36%
First animation frame808 ms520 ms-36%

What you need to do

If you're an app developer: nothing. The compiler plugin runs automatically in SDK 56, and the typeDescriptorOf replacement applies to all types with no code changes.

If you maintain an Expo module that uses Records, add @OptimizedRecord to opt into the faster conversion:

@OptimizedRecord
class MyConfig : Record {
@Field val apiUrl: String = ""
@Field val timeout: Int = 30
}

If you use props with our Compose integration, annotate with @OptimizedComposeProps:

@OptimizedComposeProps
data class MyViewProps(
val title: MutableState<String> = mutableStateOf(""),
val count: MutableState<Int> = mutableIntStateOf(0)
) : ComposeProps

Skipping these annotations doesn't break anything. The module falls back to the same reflection-based conversion from SDK 55. You just won't get the 6x speedup for Records.

What's next

This isn't the end of the road. The compiler plugin currently handles type metadata and Record conversion, but the same approach can apply to other parts of the module lifecycle, like function dispatch. We're also investing in the plugin itself, making it more capable and easier to write, so we can grow its scope without growing the maintenance cost. The goal stays the same: keep the ergonomic APIs module authors write today, while pushing even more of the work to compile time.

SDK 56
Kotlin
Expo Modules

Share article