MVP Factory
ai startup development

Baseline Profiles + Compose Navigation: P90 startup wins

KW
Krystian Wiewiór · · 5 min read

Meta description: Most teams measure the wrong startup metric. Learn how Baseline Profiles interact with Compose Navigation’s lazy loading to actually move your P90 numbers in production.


TL;DR

Baseline Profiles pre-compile your hot code paths at install time, but Compose Navigation’s lazy destination loading creates profile coverage gaps most teams never catch. Measure P90, not median. Generate profiles with a MacrobenchmarkRule that exercises real navigation flows, validate dex layout changes — not just reportFullyDrawn timing — and you will actually move the metric that users feel.


What most teams get wrong about startup

Startup benchmarks feel productive. You run Macrobenchmark, see a 30% improvement on timeToInitialDisplay, and ship the profile. Six weeks later, app store reviews about slow launches haven’t moved.

Because median startup time is not what users experience. P90 is.

The bottom 10% of your user base — older devices, limited RAM, cold boot after device restart — are the ones leaving one-star reviews. And those are exactly the users where the ART compilation tier matters most.


The ART compilation pipeline (and where profiles fit)

ART compiles DEX bytecode through several tiers:

TierDescriptionStartup Impact
InterpretedBytecode interpreted at runtimeSlowest
JIT (Just-In-Time)Compiled on first executionModerate
Profile-Guided (Partial AOT)Pre-compiled from profile rules at installFast
Full AOTEntire app pre-compiledFastest (high install cost)

Baseline Profiles target Partial AOT. At install time, dex2oat uses your .prof rules to pre-compile only the hot methods — the ones exercised during profile generation. Full AOT at install is too expensive for Play Store distribution, so this is intentional and the right tradeoff.

The practical implication: only code your profile covers gets the startup benefit. This is where Compose Navigation creates a subtle trap.


The lazy destination problem in Compose Navigation

Compose Navigation loads @Composable destinations lazily. Your NavHost registers composables by route string; they are not instantiated until the user navigates to them. On a cold start, only your start destination and its dependency graph are executed.

A naive Macrobenchmark test:

@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 because they were never executed.

The fix: exercise your critical navigation flows during measurement.

@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()
    }
}

This generates profile rules covering composables across your nav graph, not just the entry point.


Dex layout optimization

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. When your .prof file feeds dex2oat, the toolchain produces an .art file with hot classes laid out in execution order. On devices with slower flash storage, this can move P90 meaningfully while barely touching P50 — which is exactly why median measurements mislead you.

Validate this directly: inspect your APK’s DEX layout before and after profile integration using Android Studio’s APK Analyzer. If hot classes are scattered across the DEX post-profiling, your benchmark isn’t covering the right paths.


Measuring the right metric

Use both startup timing metrics from Macrobenchmark and anchor them to reportFullyDrawn() in your Activity:

  • TTID — time to first frame rendered (often just a skeleton or loading state)
  • TTFD — time to reportFullyDrawn() — when content the user actually cares about is visible

Apps that surface content immediately on launch — like HealthyDesk, which shows your next break reminder the moment you open it — live and die by TTFD. A loading spinner doesn’t remind you to stand up. Track P90 TTFD across device tiers in CI, not median TTID on your Pixel 9.


Summary

Three things worth actually doing:

  1. Exercise your top 3–5 NavGraph destinations in your MacrobenchmarkRule, not just the cold-start path to the home screen. Profile coverage that stops at the entry point is almost no profile coverage at all.

  2. Track P90 TTFD, not median TTID. Median improvements look good in CI. P90 improvements are what users on mid-range hardware actually feel, and they’re the ones writing reviews.

  3. After applying your profile, open APK Analyzer and check the DEX layout. If your hot-path classes aren’t contiguous, the profile rules aren’t covering them. Fix the benchmark instrumentation before touching app code.


android jetpackcompose kotlin mobile architecture


Share: Twitter LinkedIn