Android cold start in 2026: Hilt, R8, and Baseline Profiles
Meta description: Deep dive into Android cold start profiling with Macrobenchmark, R8 Full Mode, and Hilt lazy injection tricks that cut 150-300ms from your app launch time.
TL;DR
Your Hilt component graph initializes eagerly on the main thread. R8 Full Mode helps but doesn’t save you from DI overhead. Baseline Profiles warm the JIT ahead of time. These three levers can realistically cut 150-300ms from cold start — but only if you measure first.
The cold start budget problem
In my experience building production systems, most teams treat cold start as a “we’ll fix it later” concern — until users start churning on the Play Store. Google has published data tying sub-1000ms cold starts to measurably better retention. By 2026, the bar has moved: flagship devices are fast, but the long tail of mid-range hardware is where your P95 numbers live.
A typical Hilt-heavy app on a mid-range device at launch:
| Phase | Typical Time |
|---|---|
| Zygote fork + class loading | 80-140ms |
| Application.onCreate() | 60-180ms |
| Hilt component initialization | 50-150ms |
First Activity.onCreate() | 40-80ms |
| First frame rendered | 30-60ms |
| Total cold start | 260-610ms |
Hilt’s share of that budget is larger than most teams realize.
Measuring with Macrobenchmark tracing
Before you optimize anything, instrument with Macrobenchmark and capture a detailed trace. The key is pairing StartupMode.COLD with perfetto trace capture:
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun coldStartup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric(), TraceSectionMetric("Hilt_Init")),
iterations = 10,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
Add a custom trace section inside your Application.onCreate():
override fun onCreate() {
Trace.beginSection("Hilt_Init")
super.onCreate() // This triggers Hilt's ApplicationComponent
Trace.endSection()
}
Pull the trace into Android Studio’s Profiler or Perfetto UI. You’ll immediately see the Hilt_Init slice — and in most production apps, it’s embarrassingly wide.
R8 Full Mode: what it does and doesn’t fix
R8 Full Mode (android.enableR8.fullMode=true in gradle.properties) enables aggressive inlining, class merging, and unused code removal that the default mode skips. For Hilt specifically, it eliminates generated factory boilerplate and inlines single-use providers.
What most teams get wrong: R8 Full Mode reduces bytecode size and improves JIT compilation speed, but it doesn’t change when your object graph is constructed. Eager @Singleton bindings still execute on the main thread during Application.onCreate(). R8 makes each individual construction cheaper; it doesn’t defer it.
The Hilt tricks that actually move the needle
1. Lazy injection for non-critical singletons
Replace direct injection with Lazy<T> for anything not needed on the first frame:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Eager — costs time at onCreate()
@Inject lateinit var analyticsManager: AnalyticsManager
// Lazy — zero cost until first access
@Inject lateinit var reportingService: Lazy<ReportingService>
}
Audit every @Inject in your Application and MainActivity. Anything not needed before first frame is a candidate.
2. @Singleton scope timing audit
Not all @Singleton bindings are equal. Use the Perfetto trace to identify which @Provides methods are executing during startup and which ones are pulling in transitive dependencies — network clients, database builders, analytics SDKs.
Move heavyweight initializations behind a @Singleton + Lazy combination and initialize them off the main thread post-first-frame using a coroutineScope.launch(Dispatchers.IO) in onCreate().
3. ComponentActivity init order
ComponentActivity triggers HiltActivityComponent construction earlier than many developers expect — before your own setContent {} call. If you’re injecting ViewModel factories or SavedState handles, this component builds eagerly. Consider splitting your Hilt entry point setup to delay non-essential component wiring until after onStart().
Baseline Profiles: the multiplier
Baseline Profiles pre-compile critical execution paths via ART’s AOT pipeline, moving work from runtime JIT to install time. They compound with the above fixes:
# Generate in macrobenchmark module
@OptIn(ExperimentalBaselineProfilesApi::class)
class BaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() = baselineProfileRule.collect(
packageName = "com.example.app"
) {
startActivityAndWait()
// Add key user journeys here
}
}
The resulting baseline-prof.txt ships with your APK. On first launch, ART skips JIT for covered paths entirely. Combined with lazy injection and R8 Full Mode, this is where the 150-300ms gains become real.
Three takeaways
Measure before you optimize. Use Macrobenchmark with TraceSectionMetric and Perfetto to pinpoint exactly what Hilt is constructing during your cold start window. Guessing costs more time than instrumenting.
Audit every @Inject in Application and MainActivity. Replace non-critical dependencies with Lazy<T> and move heavy I/O initialization off the main thread. This single change is usually responsible for the biggest gains.
Ship a Baseline Profile with every release. Generate it against your real startup journeys, commit baseline-prof.txt to source control, and validate it hasn’t regressed with each CI build. It’s free performance at install time.
#android #kotlin #mobile #architecture #jetpackcompose