---
title: "Baseline Profiles + Compose Navigation: How to Actually Move Your P90 Startup Numbers"
published: true
description: "Most teams measure the wrong startup metric. Here is how Baseline Profiles interact with Compose Navigation's lazy loading to move the number users actually feel."
tags: kotlin, android, performance, mobile
canonical_url: https://mvpfactory.co/blog/baseline-profiles-compose-navigation-p90-startup
---
What You Will Build
By the end of this tutorial you will have a MacrobenchmarkRule that generates real profile coverage across your Compose NavGraph — not just the cold-start path to your home screen — and you will know exactly how to validate the result in APK Analyzer. More importantly, you will understand why median startup improvements look great in CI and do nothing for your app store reviews.
Let me show you a pattern I use in every project.
Prerequisites
- Android project with Jetpack Compose and Compose Navigation
-
benchmark:macrobenchmarklibrary configured in a:benchmarkmodule - A physical device or emulator for profiling (emulators give directional data; real hardware gives production-representative data)
The ART Compilation Pipeline — What Baseline Profiles Actually Do
ART compiles DEX bytecode through several tiers:
| Tier | Description | Startup Impact |
|---|---|---|
| Interpreted | Bytecode run at runtime | Slowest |
| JIT | Compiled on first execution | Moderate |
| Profile-Guided (Partial AOT) | Pre-compiled from .prof rules at install |
Fast |
| Full AOT | Entire app pre-compiled | Fastest (high install cost) |
Baseline Profiles target Partial AOT. At install time, dex2oat uses your .prof rules to pre-compile only the hot methods your profile covers. Full AOT is too expensive for Play Store distribution — this tradeoff is intentional and correct.
The catch: only code your profile exercises gets pre-compiled. This is where Compose Navigation creates a subtle trap.
Step 1 — Understand the Lazy Destination Problem
Compose Navigation loads @Composable destinations lazily. Your NavHost registers composables by route string; they are not instantiated until the user navigates there. On a cold start, only your start destination and its dependency graph execute.
A naive benchmark looks like this:
@Test
fun startupBenchmark() {
measureRepeated(
packageName = "com.yourapp",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
This profiles exactly the cold-start path to your start destination. Every other NavGraph destination — detail screen, settings, onboarding — generates zero profile rules. The docs do not mention this, but a profile that stops at your entry point is almost no profile coverage at all.
Step 2 — Exercise Real Navigation Flows
Here is the minimal setup to get this working:
@Test
fun startupWithCriticalNavigation() {
measureRepeated(
packageName = "com.yourapp",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
setupBlock = { pressHome() }
) {
startActivityAndWait()
device.findObject(By.res("home_tab")).click()
device.waitForIdle()
device.findObject(By.res("detail_item")).click()
device.waitForIdle()
}
}
Exercise your top 3–5 destinations during measurement. This generates profile rules covering composables across your entire nav graph, not just the entry point.
Step 3 — Validate DEX Layout, Not Just Timing
Beyond compilation tier, Baseline Profiles also drive dex layout — the physical ordering of classes and methods within the DEX file. Methods that execute together at startup are reordered to be contiguous on disk, reducing page fault overhead during class loading.
This effect is most visible on mid-range devices where I/O is the actual bottleneck, not CPU. On devices with slower flash storage, this can move P90 meaningfully while barely touching P50.
Validate it directly: open your APK in Android Studio's APK Analyzer before and after profile integration. If your hot classes are scattered across the DEX post-profiling, your benchmark is not covering the right paths. Fix the instrumentation before touching app code.
Step 4 — Measure the Right Metric
Use both metrics and anchor them to reportFullyDrawn() in your Activity:
- TTID — time to first frame (often a skeleton or spinner)
-
TTFD — time to
reportFullyDrawn()— when content the user cares about is actually visible
Apps built around immediate utility live and die by TTFD. Take HealthyDesk, a break reminder app for developers — a loading spinner does not remind you to stand up. The content has to be there on open. Track P90 TTFD across device tiers in CI, not median TTID on your Pixel 9.
Gotchas
Median startup hides the real problem. The bottom 10% of your users — older devices, limited RAM, cold boot after restart — are the ones leaving one-star reviews. P90 is the metric that users on mid-range hardware actually feel.
Profile coverage gaps are silent. No warning is emitted when a destination goes unprofiled. You have to instrument your benchmark to cover critical flows proactively.
TTID vs TTFD confusion. TTID improves are easy to achieve and easy to misread. If your first frame is a loading spinner, a 30% TTID improvement changes nothing for perceived performance.
Summary
Three things worth actually doing:
- Exercise your top 3–5
NavGraphdestinations in yourMacrobenchmarkRule, not just the cold-start entry point. - Track P90 TTFD, not median TTID — that is what users on mid-range hardware feel.
- After applying your profile, open APK Analyzer and confirm hot-path classes are contiguous. If they are not, fix the benchmark instrumentation.
Startup benchmarks feel productive. Make sure you are moving the metric that actually matters.
Top comments (0)