Baseline Profiles fix a problem recomposition counts can't see
Baseline Profiles ship a list of hot methods so ART compiles them at install time instead of interpreting them on first run. They typically cut startup and first-scroll jank by 20-30% and are unrelated to Compose recomposition.

Day 81 of 100. Two days of recomposition, and now the optimisation that has nothing to do with it and often matters more.
The symptom
An app that's fast except the first time:
Cold start to first frame: 1,400ms
Second launch: 480ms
First scroll: jank at P99
Later scrolls: smooth
Every measurement improves after the app has been used for a while. The code didn't change; the runtime did.
Profiling the first run shows time spread thinly across Compose's own internals — the runtime, the layout code, the Material components. There's no hot spot to fix, because the problem isn't your code being slow.
Why the obvious fix fails
The obvious fix is to optimise startup: defer initialisation, lazy-load dependencies, trim the application class.
Worth doing, and it addresses the wrong half. The dominant cost on a fresh install is that your code and its libraries are being interpreted, not executed as compiled machine code. No amount of deferring changes the interpretation cost of the code that does run.
The second obvious fix — "warm it up on a background thread" — makes it worse, because it competes for the same CPU during the phase you're trying to speed up.

The actual mechanism
Android's runtime, ART, compiles ahead of time selectively. On a fresh install it doesn't know which methods matter, so most code starts interpreted, gets JIT-compiled once it's observed to be hot, and eventually — after the device is idle and has collected a profile — gets compiled ahead of time.
That process takes several launches. Users judge the first one.
A Baseline Profile is a list of classes and methods, shipped in your APK or AAB, that tells the installer "compile these ahead of time". The code paths on your startup and first interaction are then native from the very first run.
The typical reported improvement is 20–30% on startup and a meaningful reduction in first-scroll jank. It's the largest single win available for most apps, and it requires no code changes at all.
Generating one
The profile is produced by running your app, not by writing a list:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait()
// Exercise the paths users hit first
device.findObject(By.res("order-list")).fling(Direction.DOWN)
device.waitForIdle()
device.findObject(By.text("Details")).click()
device.waitForIdle()
}
}
The generated file lands in src/main/baselineProfiles/ and ships with the build. The
baselineprofile Gradle plugin wires the module and the generation task.
What to exercise, in order of value:
- Cold start to first frame — always.
- The first scroll on the landing screen.
- The most common navigation — one or two destinations.
What not to do is exercise everything. A profile covering the whole app compiles more than needed, which grows the install size and dilutes the benefit. The startup path plus one or two journeys is the sweet spot.
Startup Profiles, the related thing
A Startup Profile is a narrower variant that also influences the layout of the DEX
files, grouping startup code together so less of the file needs paging in. It's generated
by the same rule with includeInStartupProfile = true, and it stacks with the baseline
profile rather than replacing it.
Worth enabling once the baseline profile is in place; the incremental win is smaller but free.
Both are distinct from the Cloud Profile Play collects from real users over time. That one is genuinely free and requires nothing from you — it also takes days to accumulate and only benefits versions that have been in the wild, which is exactly the window a shipped profile covers.
Verifying it actually applied
The failure mode here is silent — a profile that ships but doesn't apply looks exactly like no profile. Two checks:
adb shell dumpsys package dexopt | grep -A 1 com.example.app
The status should read speed-profile rather than verify. And on install, the profile
must be compiled by the device — which takes a moment after install, so measuring
immediately can miss it. adb shell cmd package compile -f -m speed-profile com.example.app
forces it for a test.
The most common reason a profile doesn't apply: the library version predates profile support, or the app was installed via a path that strips it. Sideloading an APK from Android Studio's Run button does not always deliver the profile the way Play does — which is why the benchmark below installs a release build explicitly.
Measuring the difference
This is the one optimisation where the measurement is genuinely easy, because Macrobenchmark supports comparing with and without:
@Test fun startupWithProfile() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 20,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require),
) { startActivityAndWait() }
@Test fun startupWithoutProfile() = benchmarkRule.measureRepeated(
…,
compilationMode = CompilationMode.None(),
) { startActivityAndWait() }
Two runs, two numbers, one honest comparison. CompilationMode.None() simulates the
fresh-install interpreted case; Require fails the test if the profile is missing, which is
what catches the silent non-application above.
Twenty iterations matter — cold start has high variance, and five runs will tell you whatever you want to hear. Run them on a mid-range device, for Day 78's reason: a flagship's faster JIT narrows the gap the profile exists to close.
Where it doesn't help
Being precise about scope, since it's often oversold:
Not a recomposition fix. A list that janks because a row can't skip janks identically with a profile. Days 79–80 are for that.
Not an I/O fix. A slow network call or an unindexed query is unaffected. Day 78's fourth category is still the first thing to rule out.
Diminishing on later launches. By the third or fourth launch ART has its own profile, so the benefit is concentrated exactly where it's most visible — the first impression.
And it needs regenerating. A profile records method names from the build it was generated against. Significant refactoring, a library upgrade, or a new startup path gradually erodes its coverage — silently, since a stale profile still applies, just to fewer of the methods that now matter. Regenerating on a release cadence, or wiring the generation task into the release build, keeps it honest.
What this generalizes to
The idea is the first run is a different program from the tenth, and most measurement happens on the tenth. Interpreted-then-JIT-then-AOT is invisible in a profiler pointed at a warm app, so the problem is easy to miss entirely and hard to attribute once noticed.
The broader habit: when performance varies with how long the system has been running, the cause is usually a cache or a compilation tier warming up, and the fix is to prime it rather than to make the code faster. Baseline Profiles are that fix, made shippable.
It's also the pillar's cheapest win by a distance. Days 79 and 80 need a profiler, a report, and judgement about which composables matter. This one is a Gradle plugin, a generator test, and a number that improves for every user on every fresh install — with no change to a single line of UI code.
Tomorrow, Day 82: deferred reads — the technique that has come up in six pillars, examined directly.
Day 81 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Baseline Profiles.