5 min read

Baseline Profiles: The Startup Win Most Android Apps Leave on the Table

Baseline Profiles tell the Android runtime which code to AOT-compile at install time, removing interpretation from your cold start. Here's how they work, how to generate one that reflects real usage, and how to prove the win with macrobenchmark.

AndroidPerformanceBaseline ProfilesStartupBenchmarking
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — A Baseline Profile is a list of classes and methods, shipped inside your APK or AAB, that the Android runtime ahead-of-time compiles when the app is installed. It removes bytecode interpretation and JIT warm-up from the paths users hit first. On real apps I've measured 20–30% faster cold starts with no application code changes. The catch: a profile is tied to your code, so it has to be regenerated in CI and verified with a benchmark.

What a Baseline Profile actually is

Android ships your app as DEX bytecode, not machine code. On a cold start, ART interprets that bytecode, and only after watching a method run repeatedly does the JIT compile it to native code. Your first launch is therefore the slowest your app will ever be — and it's the one a new user judges you on.

A Baseline Profile short-circuits that. It's a plain text list of method and class references:

HSPLcom/example/home/HomeViewModel;-><init>()V
HSPLcom/example/home/HomeScreenKt;->HomeList(Landroidx/compose/runtime/Composer;I)V
Lcom/example/home/HomeUiState;

The flags matter: H means hot, S startup, P post-startup. At install time (or during a background dexopt pass), ART compiles those members ahead of time. When the user launches, that code is already native.

Why it beats most hand-written optimisations

Startup work splits roughly into: process fork, Application init, first Activity/Composition, and first frame. Teams tend to attack this by lazily initialising libraries and shaving work out of Application.onCreate(). That's worth doing, and it's bounded — you can only delete work you control.

Baseline Profiles attack a different axis: the execution speed of the work that remains. You aren't removing anything; you're making all of it faster. That's why the win compounds with the size of your startup path rather than shrinking as you optimise.

Generating one that reflects reality

Use the androidx.baselineprofile Gradle plugin and write a generator that drives the journey a real user takes first — not a tour of your entire app.

@Test
fun generate() = baselineProfileRule.collect(
    packageName = "com.example.app",
    includeInStartupProfile = true,
) {
    startActivityAndWait()
    device.findObject(By.res("home_list")).fling(Direction.DOWN)
    device.waitForIdle()
    device.findObject(By.res("item_0")).click()
    device.waitForIdle()
}

Two rules I hold to:

  • Cover the first 10 seconds, not the whole product. Launch, the first list, one detail screen. A bloated profile dilutes the benefit and inflates install-time compilation.
  • Include the startup profile. includeInStartupProfile = true feeds the subset ART uses to optimise DEX layout, so startup-critical code sits together on disk.

Proving the win

Never ship a performance change you haven't measured. macrobenchmark lets you compare compilation modes directly:

@Test
fun startupNone() = measure(CompilationMode.None())

@Test
fun startupBaseline() = measure(CompilationMode.Partial(BaselineProfileMode.Require))

private fun measure(mode: CompilationMode) = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(StartupTimingMetric()),
    compilationMode = mode,
    startupMode = StartupMode.COLD,
    iterations = 10,
) { startActivityAndWait() }

CompilationMode.None() is your floor — an interpreted cold start. Partial(Require) fails the run if the profile isn't actually installed, which is what you want in CI: a silent no-op is worse than a red build. Compare median and P90, not a single run.

The three failure modes I keep seeing

A profile generated once and forgotten. It references methods by name. Rename a class, restructure a screen, and those entries stop matching — the profile degrades silently, with no build error. Regenerate it on a schedule or on release branches.

Profiling the wrong journey. A generator that logs in, visits settings, and opens five tabs produces a profile optimised for a session nobody has. Match your actual first-run funnel.

Assuming it worked. Verify installation on device with dumpsys package dexopt | grep <your.package> and look for a status=speed-profile entry. On a debug build the profile isn't applied at all, which is the single most common reason someone concludes "Baseline Profiles do nothing".

Where this sits in a performance strategy

Baseline Profiles are the highest ratio of win-to-risk in Android performance work: no architectural change, no new abstractions, no runtime behaviour difference. They belong in the same tier as enabling R8 properly and fixing your startup dependency graph — configuration-level wins you do once, verify, and keep honest in CI.

The deeper point is one I'd apply to any senior performance work: the largest improvements are rarely clever. They come from understanding what the platform is doing on your behalf, and then telling it something it couldn't have known.

Keep reading