Most Compose performance advice ends at “add the plugin and run the generator.” I wanted to know what that generated file changes on a real device, and that question pulled me below Compose to the runtime that executes every line we ship. This post follows that thread: what ART does with bytecode, where its compilation hints come from in production, why those hints arrive late for a brand-new release, and how a Baseline Profile moves them earlier. Along the way you’ll get Kotlin and Gradle you can adapt, plus the quiet ways a profile stops working.
How ART executes your code
Your Kotlin compiles to JVM bytecode, then D8/R8 turns it into DEX. ART has three ways of running that DEX.
- Interpreter. Steps through the bytecode and carries out each instruction in turn. It needs no preparation, so anything can run right away, but it is the slowest option once code is hot.
- JIT (just-in-time). Watches what runs and turns methods that keep getting called into machine code while the app is live. It follows real behavior, but it burns CPU and battery in the foreground.
- AOT (ahead-of-time). Builds machine code for a chosen subset of the app before that code is first needed. The price is storage and compile time, paid off the critical path.
Note the word selected. Compiling everything ahead of time is possible, but it inflates install size, dexopt time, and storage use. Modern ART compiles selectively, which raises the real question.
Why AOT matters for startup
An interpreted method pays an overhead on every call. A method waiting on the JIT pays twice: it runs slowly first, and the compile work competes with your app for CPU. AOT-compiled code skips both. That is why cold start, the first navigation, and the first scroll are the moments where AOT shows up most.
Jetpack Compose raises the stakes. Compose and its dependencies ship with your app as library code rather than living in the platform image. A cold launch therefore runs a lot of runtime and layout code that nobody has compiled yet on a fresh install.
Where does ART get its hints?
ART needs a profile: a list of classes and methods worth precompiling. In production, the first source of that list is the Cloud Profile.
As people use the app, their devices note which methods get hot. Google Play merges those observations across many users and hands the result to later installs of the same version. Its strength is honesty: it reflects how the app is actually used, not how the team pictures it.
The gap Cloud Profiles can’t fill
Look at the left edge of that pipeline. A Cloud Profile can’t exist until people have used the release. Two consequences follow:
- A new version starts cold. Early adopters, often the users who rate you first, get the least optimized experience.
- You can’t steer one. A redesigned onboarding flow or a new home screen can’t be added by hand. Those code paths show up only after enough users have walked through them.
The chart above is an illustration of the idea, not measured data. The shaded area is the day-zero gap.
What Baseline Profiles actually are
A Baseline Profile is a list of rules, shipped inside your app, that names the classes and methods you consider important. A few details are easy to get wrong:
- It is a hint, not a compiler. ART still does the compiling; the profile only shapes which code it picks.
- You author it before release. You script the journeys that matter (startup, first navigation, a key scroll) and the profile is generated from them.
- It complements Cloud Profiles rather than replacing them. Yours covers what you already know is critical at launch; the cloud one keeps tracking real behavior.
One is learned from the crowd after launch; the other is written by the team before it.
What a profile rule looks like
The generated file, baseline-prof.txt, uses one rule per line. A flag prefix says why the method matters: H for hot, S for startup, P for post-startup.
HSPLcom/tidelog/app/TideLogApplication;-><init>()V
HSPLcom/tidelog/app/MainActivity;->onCreate(Landroid/os/Bundle;)V
HSPLcom/tidelog/harbors/HarborListScreenKt;->HarborListScreen(Landroidx/compose/runtime/Composer;I)V
PLcom/tidelog/harbors/HarborDetailViewModel;->loadForecast(Ljava/lang/String;)V
Lcom/tidelog/harbors/HarborListItemKt;
You rarely write these by hand. You describe journeys in code and let tooling produce them.
The workflow
The examples below use an invented app, TideLog, which shows harbors, tide tables, and forecasts.
1. Wire up the modules
Create a profile-producer module with the Baseline Profile Gradle plugin and connect it to the app module.
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("androidx.baselineprofile")
}
dependencies {
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
baselineProfile(project(":baselineprofile"))
}
Check the current stable versions of the plugin and libraries before copying anything. They move.
2. Define the journeys
A journey helper keeps every generated profile and every benchmark exercising identical steps. That sameness matters for reasons we’ll see in the failure modes.
// baselineprofile/src/main/kotlin/com/tidelog/baselineprofile/Journeys.kt
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
private const val TIMEOUT_MS = 5_000L
/** Cold start through to the first fully rendered harbor list. */
fun MacrobenchmarkScope.startToHarborList() {
pressHome()
startActivityAndWait()
device.wait(Until.hasObject(By.res("harbor_list")), TIMEOUT_MS)
?: error("Harbor list never appeared; fix the journey, not the timeout")
}
/** Scroll the list, then open the first harbor's detail screen. */
fun MacrobenchmarkScope.browseAndOpenHarbor() {
val list = device.findObject(By.res("harbor_list")) ?: error("No harbor list")
list.setGestureMargin(device.displayWidth / 5)
repeat(3) { list.fling(Direction.DOWN) }
device.waitForIdle()
device.findObject(By.res("harbor_item_0"))?.click() ?: error("No first item")
device.wait(Until.hasObject(By.res("tide_chart")), TIMEOUT_MS)
?: error("Detail screen did not load")
}
Using error(...) on a missing element is deliberate. A journey that silently does nothing produces a profile that silently covers nothing.
3. Generate the profile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TideLogProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(
packageName = "com.tidelog.app",
includeInStartupProfile = true,
) {
startToHarborList()
browseAndOpenHarbor()
}
}
Generation needs a suitable device: Android 9 (API 28) or higher with root access, or Android 13 (API 33) or higher without root. Generate against a build that mirrors release, not a debuggable one.
4. Ship it and let ART consume it
baseline-prof.txt is packaged into your app. On supported devices, the androidx.profileinstaller library installs the rules so ART can compile the listed code, and Google Play can also use the profile when you publish. You don't write install logic yourself, but you do need to leave the library's initialization alone.
5. Prove the effect
Don’t assume the profile helps. Measure the same journey with and without it.
import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TideLogStartupBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun coldStartNoProfile() = measureStartup(CompilationMode.None())
@Test
fun coldStartWithProfile() =
measureStartup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
private fun measureStartup(mode: CompilationMode) = rule.measureRepeated(
packageName = "com.tidelog.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = mode,
) {
startToHarborList()
}
}
BaselineProfileMode.Require makes the benchmark fail if the profile isn't installed. That failure is useful, because it catches a profile that has gone missing.
Failure modes I’d check for
These are the ways a Baseline Profile stops working without anyone noticing.
1. The profile is stale. Refactors rename and move classes. Rules that name methods that no longer exist are simply ignored, so nothing crashes and the benefit erodes. Regenerate when critical journeys or major screens change, and consider regenerating as part of release builds.
2. Obfuscation mismatch. With R8 enabled, shipped names differ from source names. The Gradle plugin and a release-like build type are designed to handle this, but a hand-edited file or a profile generated from a non-minified build can fail to match. If a profile helps in one build type but not in release, check this first.
3. Generated on the wrong build. A debuggable build behaves differently under ART, and a profile from it may not reflect your release code. Generate and benchmark against a non-debuggable, release-like variant.
4. A flaky journey. If the test sometimes lands on a login screen or an error state, you capture the wrong code. Wait on specific UI elements, fail loudly when they don’t appear, and keep any test data deterministic.
5. Profiling everything. More rules aren’t better. A huge profile pushes more code into AOT, which costs storage and compile time, and it dilutes the value of “startup” rules. Keep the journeys focused on what users hit first and most often.
6. The installer never runs. If a custom setup removes or disables ProfileInstaller’s initialization, the rules may never reach ART on devices that depend on it. A quick runtime check makes this visible:
import android.util.Log
import androidx.profileinstaller.ProfileVerifier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.guava.await
suspend fun logProfileStatus() {
val label = try {
val status = ProfileVerifier.getCompilationStatusAsync().await()
when {
status.isCompiledWithProfile -> "compiled-with-profile"
status.hasProfileEnqueuedForCompilation() -> "profile-enqueued"
else -> "no-profile (code=${status.profileInstallResultCode})"
}
} catch (e: CancellationException) {
throw e // never swallow coroutine cancellation
} catch (e: Exception) {
// The check can fail on restricted devices or with a corrupted package state.
// Telemetry must never break the app, so record the failure and move on.
"verifier-failed (${e::class.java.simpleName})"
}
Log.i("TideLog", "ART profile status: $label")
}
Send that label to your analytics. The verifier-failed branch matters too: a spike in it tells you the check itself is unreliable on some devices, which is different from the profile being absent. Then "did the profile install?" becomes a question you can answer from real devices rather than guess.
7. Benchmarking the wrong thing. Comparing a profile build against a warm app, or comparing different journeys, produces numbers that look like wins. Use cold starts, identical journeys, and enough iterations that noise doesn’t decide the result.
8. Treating the profile as one-time work. Cloud Profiles keep adapting to users. A Baseline Profile only changes when you regenerate it. Put regeneration in your release checklist or CI.
🔚 Takeaways
- ART has three execution paths. Precompiling the right subset spares users both slow interpretation and JIT warm-up.
- Cloud Profiles reflect real usage, but they can only exist after that usage happens, and you can’t hand-edit them.
- A Baseline Profile is a prioritized list you author from scripted journeys. ART still does the compiling.
- Use both: ship with a head start, then let production data keep refining it.
- Measure
CompilationMode.None()against a profile-backed mode, and verify installation on real devices.
Ship with a head start, and let production data do the fine-tuning.
📱 Go Beyond Using Jetpack Compose
If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.





Top comments (0)