Baseline Profiles on Android: what actually moves your cold-start time in 2026
A practical guide to Android Baseline Profiles — generating one with Macrobenchmark, wiring it into Gradle, measuring the real gain, and the three other things that move cold start more.
Cold start is the one performance number every user feels and almost no one measures. You tap the icon, there’s a beat of blank screen or a stretched splash, then the app appears. Under 500ms and nobody notices. Past a second, it reads as “slow” even if every screen after that is instant — first impressions are sticky, and the app icon is the first one.
Most of that number isn’t your layout or your database query. It’s the JIT compiler doing cold, interpreted work on your app’s hottest code paths before it has any profile to optimize against. Baseline Profiles exist to skip that warm-up, and they’re one of the few Android performance tools that cost you an afternoon once and keep paying for every install after.
What a Baseline Profile actually is
A Baseline Profile is a text file — baseline-prof.txt — listing the classes and methods your app touches during its most common flows: cold start, and whichever other journeys you choose to profile. You ship it inside the APK/AAB, and Android Runtime (ART) uses it to pre-compile those methods ahead-of-time instead of interpreting or JIT-compiling them on first use.
The effect is narrow but real: it doesn’t make your code faster, it removes the interpretation tax on the specific code that runs during startup. On Play, Google reports this as commonly cutting cold start time in the 20-30% range for apps that didn’t already have one — your mileage depends entirely on how much of your startup path is actually covered.
Generating one with Macrobenchmark
The profile has to come from a real, instrumented run — you can’t hand-write it usefully. The androidx.benchmark.macro library does this:
// build.gradle.kts (baselineprofile module)
plugins {
id("androidx.baselineprofile")
}
dependencies {
baselineProfile(project(":app"))
}
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(
packageName = "com.mfk.hydrame",
includeInStartupProfile = true,
) {
pressHome()
startActivityAndWait()
// Exercise the flows worth pre-compiling — not just the splash.
device.findObject(By.text("Log a glass")).click()
device.waitForIdle()
}
}
Run the :app:generateBaselineProfile Gradle task on a real device or a reasonably fast emulator (profile generation needs a rooted-equivalent instrumentation build, which the plugin handles). It records every class touched during that scripted run and writes baseline-prof.txt into app/src/main/, where the AGP baseline profile plugin automatically picks it up for release builds.
The part people skip: script more than the splash screen. If the real bottleneck is your first list rendering with data from Room, or a widget’s initial provider call, include that interaction in the collect block. A profile that only covers onCreate() misses most of the actual win.
Measuring the gain honestly
Don’t trust a stopwatch and a thumb. Use StartupTimingMetric in a separate macrobenchmark test, run it against a build without the profile and a build with it, and compare medians across enough iterations that noise averages out:
@Test
fun startupCompilationModes() {
benchmarkRule.measureRepeated(
packageName = "com.mfk.hydrame",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require),
) {
pressHome()
startActivityAndWait()
}
}
Swap compilationMode to CompilationMode.None() for the baseline comparison run. Ten iterations is a reasonable floor — device thermal state and background processes add enough jitter that three runs will lie to you in either direction.
What Baseline Profiles don’t fix
They’re not a substitute for actually reducing startup work. If your Application.onCreate() initializes three SDKs, opens the database eagerly, and inflates a heavy first screen, a profile makes that work faster to interpret — it doesn’t make there be less of it. The gains compound with, not instead of:
- Deferring non-critical init. Anything that isn’t needed for the first frame — analytics SDKs, non-essential WorkManager scheduling, remote config fetches — goes on a background dispatcher after the UI is visible, not in
onCreate(). - R8 full mode in release builds. Shrinking and optimizing the bytecode means there’s less of it for ART to walk through in the first place, profile or not.
- Lazy singletons instead of eager ones. A
by lazydatabase instance that initializes on first DAO access costs you nothing at startup if the first screen doesn’t need it yet.
I found this out building Hydrame’s home-screen widget: the widget’s own cold start (a separate process context, no warm Application to lean on) was slower than the app’s, because none of the widget’s code path was in the profile at all. Adding a widget-specific scenario to the Macrobenchmark script — not just the app’s main activity — closed most of that gap.
Where this is worth the afternoon
If your app is already fast, a Baseline Profile is a nice-to-have. If cold start is the one metric in Play Console’s vitals dashboard sitting in the yellow, it’s usually the highest-leverage afternoon you can spend: no architecture change, no feature risk, just a generated file and a Gradle plugin. Generate it once per release from your actual critical paths, keep the Macrobenchmark test in CI so a redesign doesn’t silently blow past what the profile covers, and treat the cold-start number the same way you’d treat any other regression — something you watch, not something you fix once and forget.
// Related reading
More from the journal
Jetpack DataStore in 2026: migrating off SharedPreferences without losing a setting
A practical guide to moving from SharedPreferences to Jetpack DataStore on Android — the async pitfalls, the migration path that preserves existing values, and how to test it.
Building Mintly: keeping a focus timer accurate when Android kills the process
A running Pomodoro timer has a harder reliability problem than a one-shot reminder. Here's how Mintly survives Doze, process death, and screen-off drift with a foreground service and a wall-clock end time.
ML Kit barcode scanning on Android in 2026: how Stocky adds a pantry item in under a second
A practical 2026 guide to on-device barcode scanning with ML Kit and CameraX: format tuning, offline product lookup, and the partial-use math behind a pantry app.