5 min read

App Startup: The Five Phases and Where Your Time Really Goes

A single startup number tells you nothing actionable. Startup is five phases with five different fixes — and the one that usually dominates, time-to-usable, is the one nobody measures. How to attribute the time before optimising it.

AndroidPerformanceStartupBenchmarkingApp Startup
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — "We start in 2.1 seconds" isn't actionable. Startup splits into process/ART init, Application.onCreate(), Activity creation and first composition, first frame draw, and time to actually usable. Each has a different fix. The last one — a drawn skeleton waiting on data — usually dominates the user's experience and is the one standard metrics hide. Attribute first, optimise second.

Why one number misleads

Generic startup advice ("lazy-init your libraries", "use App Startup", "add a Baseline Profile") helps dramatically on some apps and does nothing on others. That's not because the advice is wrong; it's because each tip targets one phase, and you don't know which phase owns your time.

So attribution comes first.

Phase 1: process fork and ART init

The system forks Zygote, creates your process, and loads your DEX. You don't run any code here, which makes it feel unfixable. Two levers exist:

  • Less code. R8 shrinking directly reduces what has to be loaded and verified.
  • A Baseline Profile, so the code that does run is AOT-compiled rather than interpreted.

Typically 100–300ms, and largely proportional to app size.

Phase 2: Application.onCreate()

Every library you initialise, synchronously, before anything appears. This is where most teams have their largest available win, and it accumulates invisibly — each SDK's docs say "call this in onCreate", and eleven of them do.

override fun onCreate() {
    super.onCreate()
    analytics.init(this)     // opens a database
    crashReporter.init(this) // reads a file
    imageLoader.warmUp()     // touches disk
    remoteConfig.fetch()     // network on the critical path
}

Audit it with a trace and move everything that isn't needed for the first frame:

// Deferred until after first frame
Handler(Looper.getMainLooper()).post { analytics.init(this) }

// Or genuinely lazy — initialised on first use
private val analytics by lazy { Analytics(this) }

Jetpack App Startup helps you express initialiser dependencies, but it doesn't decide what's deferrable. That judgement is yours: does the first frame need this?

Phase 3: Activity creation and first composition

Building the first screen: onCreate, dependency injection, the first composition or inflation.

The lever here is a simpler first screen, not a cleverer one. A home screen with six independently loading sections costs more than a screen that shows one thing. Deep view hierarchies and heavy @Composable initialisation both land here.

Watch for synchronous work hidden in constructors — a ViewModel that reads DataStore in its init block is doing disk I/O on your critical path.

Phase 4: first frame draw

Measure, layout, draw. Overdraw, complex backgrounds, and shadows on the first screen show up here. It's usually the smallest phase, and it's the one a Perfetto trace attributes most clearly (RenderThread rather than main thread).

Phase 5: time to actually usable

The phase that matters most and gets measured least.

Your first frame is drawn — and it's a skeleton. Real content arrives 800ms later when the network responds. Your metric says 900ms; the user experienced 1.7 seconds of not being able to do anything.

Tell the system when you're genuinely ready:

LaunchedEffect(uiState) {
    if (uiState is Content) {
        activity.reportFullyDrawn()
    }
}

This produces a fullyDrawn metric that reflects reality, and it's what StartupTimingMetric reports alongside the initial display time.

The fixes here are different in kind from phases 1–4: cache the last response and render it immediately, make the skeleton show real cached data rather than grey boxes, or reduce what the first screen needs before it's useful. None of that is about initialisation speed.

Measuring properly

# TotalTime covers phases 1–4
adb shell am start-activity -W -n com.example/.MainActivity

# Repeatable, statistically meaningful, both metrics
./gradlew :benchmark:connectedCheck
@Test fun startup() = benchmarkRule.measureRepeated(
    packageName = "com.example",
    metrics = listOf(StartupTimingMetric()),
    startupMode = StartupMode.COLD,
    iterations = 10,
) { startActivityAndWait() }

Ten iterations, and read the median and P90 — a single cold start on a warm device is noise. And always measure a release build: debug builds skip R8 and don't apply Baseline Profiles, so debug startup numbers are not predictions.

The trap

The most common wasted sprint in Android performance work: optimising phase 2 when phase 5 owns the problem. You spend a week rearranging initialisers, the startup metric improves by 120ms, and the app feels exactly as slow — because the user was waiting on the network the entire time.

Attribute the time first. Then optimise the phase that actually holds it.

Keep reading