Android Baseline Profiles for Startup Performance
Android Baseline Profiles startup optimization is one of the most impactful performance improvements you can make to any Android application. Baseline Profiles provide AOT (Ahead-of-Time) compilation hints to the Android Runtime (ART), telling it which code paths are critical and should be pre-compiled at install time rather than JIT-compiled at runtime. According to Google’s own benchmarks, this typically reduces cold start time by 30-40% and removes much of the jank during the first few interactions, all without changing a single line of application logic.
This guide covers creating, testing, and distributing Baseline Profiles for production applications, from Macrobenchmark setup to profile generation, custom journey coverage, and CI/CD integration. Moreover, you will see how to measure the actual impact on startup metrics and how to optimize both cold start and warm start scenarios so that the win is verified rather than assumed.
Understanding the ART Compilation Pipeline
When you install an Android app, ART uses a combination of interpretation, JIT compilation, and profile-guided AOT compilation to execute your code. Without Baseline Profiles, the app starts with interpreted bytecode (slow), JIT-compiles hot methods over time, and eventually AOT-compiles based on locally collected usage profiles. As a result, the first several launches a user experiences are measurably slower than the ones that come after the device has had time to “warm up.”
Furthermore, Baseline Profiles short-circuit this process by shipping compilation hints inside the app itself. Google Play (or the installer on older setups) reads the profile and compiles the listed methods before the user first opens the app. Consequently, the very first launch already benefits from pre-compiled native code on the critical path, which is exactly the moment when a slow start does the most damage to retention.
Setting Up Macrobenchmark for Baseline Profiles
// Add benchmark module to your project
// settings.gradle.kts
include(":app")
include(":benchmark")
// benchmark/build.gradle.kts
plugins {
id("com.android.test")
id("org.jetbrains.kotlin.android")
id("androidx.baselineprofile")
}
android {
namespace = "com.myapp.benchmark"
compileSdk = 35
defaultConfig {
minSdk = 28
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
}
baselineProfile {
useConnectedDevices = true
}
dependencies {
implementation("androidx.test.ext:junit:1.2.1")
implementation("androidx.test.espresso:espresso-core:3.6.1")
implementation("androidx.test.uiautomator:uiautomator:2.3.0")
implementation("androidx.benchmark:benchmark-macro-junit4:1.3.3")
}
Why minSdk 28 Matters Here
The benchmark module above targets minSdk = 28 for a reason worth understanding. On Android 9 (API 28) and later, ART can apply Cloud Profiles and install-time AOT compilation, so a shipped Baseline Profile is honored directly. On API 24 through 27, the platform still benefits, but through a slightly different ahead-of-time path. Below API 24 there is no ART profile mechanism to hook into at all, so the profile is simply ignored — harmless, but not helpful. In practice this means your profile delivers its full value to the overwhelming majority of active devices, while older handsets degrade gracefully to standard JIT behaviour rather than breaking.
One subtle gotcha: the androidx.baselineprofile Gradle plugin must be applied to both the :app module (as a consumer) and the :benchmark module (as a producer). A common setup mistake is applying it only to the test module, in which case the generated baseline-prof.txt never actually gets bundled into the release APK or AAB, and the profile silently does nothing in production.
Android Baseline Profiles: Generating the Profile
// benchmark/src/main/kotlin/BaselineProfileGenerator.kt
package com.myapp.benchmark
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateBaselineProfile() {
rule.collect(
packageName = "com.myapp",
stableIterations = 3,
maxIterations = 10,
includeInStartupProfile = true
) {
// Cold start — app launch
pressHome()
startActivityAndWait()
// Critical user journey 1: Browse products
device.findObject(By.res("product_list")).wait(Until.hasObject(
By.res("product_card")), 5000)
// Scroll the product list
val list = device.findObject(By.res("product_list"))
list.scroll(Direction.DOWN, 1.0f)
list.scroll(Direction.DOWN, 1.0f)
device.waitForIdle()
// Critical user journey 2: View product detail
device.findObject(By.res("product_card")).click()
device.findObject(By.res("product_detail")).wait(
Until.hasObject(By.res("add_to_cart")), 5000)
// Critical user journey 3: Add to cart
device.findObject(By.res("add_to_cart")).click()
device.waitForIdle()
// Critical user journey 4: Open cart
device.findObject(By.res("cart_icon")).click()
device.findObject(By.res("cart_list")).wait(
Until.hasObject(By.res("cart_item")), 5000)
// Critical user journey 5: Search
device.pressBack()
device.findObject(By.res("search_icon")).click()
device.findObject(By.res("search_input")).text = "shoes"
device.waitForIdle()
Thread.sleep(2000) // Wait for search results
}
}
}
Choosing Which Journeys to Capture
The instinct to “profile everything” is counterproductive. Every method you include is a method ART must compile at install time, which inflates the APK’s optimized footprint and lengthens the install step. Therefore, the goal is to cover the paths a real user hits in their first session, not the entire app. The includeInStartupProfile = true flag above is significant: it splits the output into a broad Baseline Profile and a narrower Startup Profile, and the Startup Profile additionally influences DEX layout so that startup classes sit next to each other on disk for faster reads.
A practical selection heuristic looks like this. Always include the launch-to-first-frame path. Then add the one or two journeys that follow naturally from launch — for an e-commerce app that is browse and view-detail, for a news app it is the feed and an article. Deliberately exclude rare administrative flows, settings screens, and onboarding that runs only once, because compiling them steals budget from the paths that fire on every single launch.
Startup Benchmarks: Measuring the Impact
Therefore, measuring the actual impact of Baseline Profiles requires systematic benchmarking with Macrobenchmark. The honest way to do this is to run the same startup benchmark under three compilation modes — none, partial-with-profile, and full — so you can see where your profile lands relative to both the worst case and the theoretical ceiling.
Key Takeaways
- Start with a solid foundation and build incrementally based on your requirements
- Test thoroughly in staging before deploying to production environments
- Monitor performance metrics and iterate based on real-world data
- Follow security best practices and keep dependencies up to date
- Document architectural decisions for future team members
// benchmark/src/main/kotlin/StartupBenchmark.kt
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun startupCompilationNone() = startup(CompilationMode.None())
@Test
fun startupCompilationPartial() = startup(
CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
)
)
@Test
fun startupCompilationFull() = startup(CompilationMode.Full())
private fun startup(compilationMode: CompilationMode) {
rule.measureRepeated(
packageName = "com.myapp",
metrics = listOf(
StartupTimingMetric(),
TraceSectionMetric("ActivityCreate"),
TraceSectionMetric("FirstDraw"),
TraceSectionMetric("DataLoaded"),
),
iterations = 10,
compilationMode = compilationMode,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
// Wait for meaningful content to render
device.findObject(By.res("product_list")).wait(
Until.hasObject(By.res("product_card")), 10000
)
}
}
@Test
fun scrollPerformanceWithProfile() {
rule.measureRepeated(
packageName = "com.myapp",
metrics = listOf(
FrameTimingMetric(),
),
iterations = 5,
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
),
startupMode = StartupMode.WARM
) {
startActivityAndWait()
val list = device.findObject(By.res("product_list"))
list.wait(Until.hasObject(By.res("product_card")), 5000)
// Measure scroll performance
repeat(5) {
list.scroll(Direction.DOWN, 1.0f)
device.waitForIdle()
}
}
}
}
Reading the Numbers Without Fooling Yourself
Two details separate a trustworthy benchmark from a misleading one. First, always run on a physical device or a consistent managed emulator, never on a shared CI runner with variable load, because startup timing is extremely sensitive to background contention. Second, treat CompilationMode.Full() as a reference ceiling, not a shipping option — full compilation pre-compiles the entire app and bloats install size, so its only purpose here is to show you how much of the available gap your partial profile actually closed.
The metric that matters most for users is timeToInitialDisplay from StartupTimingMetric, but the TraceSectionMetric entries are what make a regression diagnosable. If ActivityCreate is flat between runs while DataLoaded balloons, the problem is your data layer, not class loading, and no Baseline Profile will rescue it. Custom trace sections therefore turn a single opaque number into an actionable breakdown.
// In the app, wrap the section you want to measure
import androidx.tracing.trace
class ProductRepository {
suspend fun load(): List<Product> = trace("DataLoaded") {
// network + parse + cache work the benchmark will surface
api.fetchProducts().also { cache.put(it) }
}
}
CI/CD Integration
Additionally, generating Baseline Profiles should be part of your CI/CD pipeline so they stay current as your code changes. Stale profiles are not merely useless — they can actively hurt, because a profile that references renamed or deleted methods wastes install-time compilation budget on code that no longer runs.
# .github/workflows/baseline-profile.yml
name: Generate Baseline Profile
on:
push:
branches: [main]
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Enable KVM for emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Generate Baseline Profile
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
profile: pixel_6
script: ./gradlew :benchmark:pixel6Api34BenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.myapp.benchmark.BaselineProfileGenerator
- name: Copy profile to app module
run: cp benchmark/build/outputs/managed_device_android_test_additional_output/pixel6Api34/BaselineProfileGenerator_generateBaselineProfile-baseline-prof.txt app/src/main/baseline-prof.txt
- name: Create PR with updated profile
uses: peter-evans/create-pull-request@v6
with:
title: "Update Baseline Profile"
commit-message: "chore: regenerate baseline profile"
branch: update-baseline-profile
Why a Pull Request, Not a Direct Commit
Notice that the workflow opens a PR rather than pushing the regenerated profile straight to main. This is intentional. A profile generated on an emulator under heavy CI load can occasionally be incomplete if a journey times out, and silently overwriting a known-good profile with a degraded one is a real failure mode. Routing the change through a PR gives you a diff to eyeball and a benchmark run to gate on, so a bad profile is caught before it reaches users. Regenerating on every merge to main is usually frequent enough; regenerating per pull request burns emulator minutes for little extra benefit.
When NOT to Use Baseline Profiles
Baseline Profiles add build complexity and require a physical device or emulator for generation. For apps with extremely simple UIs (single-screen utilities, calculator-style tools), the startup improvement may be negligible — ART’s JIT compiler already handles trivial code paths efficiently. As a result, the effort to maintain benchmarks and profile generation is rarely worthwhile for an app that already cold-starts in well under 500ms.
Additionally, Baseline Profiles only address cold and warm starts. If your real pain is rendering — dropped frames during scrolling, slow animations, jank on a heavy list — a profile alone will not fix it. Those problems live in the rendering pipeline and need direct attention through lazy loading, view recycling, or Compose-specific optimizations. In short, reach for Baseline Profiles when your trace shows time lost to class loading and JIT warm-up, and reach for other tools when it shows time lost in layout, measure, or draw.
Key Takeaways
Android Baseline Profiles startup optimization delivers measurable cold-start improvements of roughly 30-40% with relatively low implementation effort. The Macrobenchmark library provides reliable measurement, profile generation automates cleanly in CI/CD, and covering the first-session journeys ensures that not just launch but also first-interaction performance improves. Crucially, you should always verify the gain with a before-and-after benchmark rather than trusting the headline figure.
Start by adding the benchmark module and generating a basic startup profile — even without custom journey coverage, you will see meaningful improvements. For detailed reference, consult the Android Baseline Profiles documentation and the Macrobenchmark guide. Our posts on Jetpack Compose performance and Kotlin Multiplatform with Compose provide complementary optimization strategies.
In conclusion, Android Baseline Profiles Startup is an essential topic for modern mobile development. By applying the patterns and practices covered in this guide — scoping journeys carefully, measuring across compilation modes, and keeping profiles fresh through CI — you can build apps that feel fast from the very first tap. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting real value rather than a comforting number.