App Startup Time Explained

PERFORMANCE › Runtime

Every Android app launch falls into one of three states, and the entire startup topic is really about which pieces of work each state reuses versus rebuilds. A cold start means the app's process does not exist yet: the system forks a brand new process, constructs the Application object, creates the main thread, then builds and inflates the first activity before drawing anything. This is the expensive path, and it is the one Android Vitals actually measures. A warm start means the process survived, but the activity was destroyed, so it runs through onCreate() again without a process fork or a new Application object. A hot start is the cheapest of the three: the activity is still resident in memory, and the system just brings it back to the foreground, with no object construction and no inflation at all. When an interviewer asks about this, they want to hear which specific pieces of work are skipped at each level, not just the three names recited back.

The moment a cold launch begins, the user is not staring at a blank black screen. The system immediately shows a starting window, a placeholder drawn before your process has even finished forking, so the launch feels responsive right away. On Android 12 and higher this starting window is themed using your app's icon and background colour, which is exactly what the splash screen API controls, covered a little later. The starting window stays up until your activity has actually drawn its first real frame, at which point the system swaps it out. Knowing this matters for two reasons: it explains why a slow first frame shows up as a stuck splash rather than a frozen black screen, and it is the mechanism the splash screen API hooks into rather than replaces.

Inside that cold start window, before a single line of your Application.onCreate() runs, the framework has already done real work. Right after attachBaseContext(), it instantiates every ContentProvider your manifest declares and calls each one's onCreate(), and only after every provider has finished does Application.onCreate() run. This ordering is not a trivia detail, it is why so many libraries historically shipped a content provider whose only job was to auto-initialize the SDK: doing so guaranteed the library was ready before your own code executed. The catch is that providers have no defined ordering relative to each other, and each one is a real instantiation cost sitting on the critical path before your first frame. That specific pain, an unordered pile of provider-based initializers, is exactly the problem the App Startup library, covered shortly, was built to solve.

Warm and hot starts get conflated a lot, so it is worth being precise about the one thing that actually separates them. In a warm start the process survived, but the activity itself was torn down, most commonly because the system reclaimed memory, so the activity has to be rebuilt: onCreate() runs again, views get reinflated, and state has to be restored. In a hot start the activity was never destroyed at all, it is still sitting in memory exactly as the user left it, so the system just moves it back to the foreground. No onCreate(), no inflation, nothing to rebuild. If you remember one thing, remember that the deciding question is whether the activity still exists, not whether the process still exists, since the process survives in both cases.

Two metrics describe how fast a launch feels, and only one of them costs you zero lines of code. Time To Initial Display, TTID, is the time until the first frame draws, and the framework reports it for you automatically: you will see it appear as a Displayed line in logcat the moment that frame is up, with no instrumentation required on your part. The other metric, Time To Full Display, is not automatic, and that gap is the whole reason the next section exists. Interviewers ask about this pairing specifically because a fast TTID can hide a screen that looks instant for one frame and then sits on a spinner for several more seconds.

TTFD exists because the framework genuinely cannot know when your screen is done loading. It knows the exact millisecond the first frame is drawn, but it has no visibility into a network call that is still in flight or a database query that has not returned, so it cannot decide on its own when the content the user actually wants is on screen. That is why the app has to say so explicitly, by calling reportFullyDrawn() once the real content, not a placeholder or a spinner, is visible. Skip this call and your TTFD is simply never recorded, which is a common reason teams believe their startup is fast when the metric that would have told them otherwise was never populated.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        loadDataAsync { data ->
            populateUi(data)
            reportFullyDrawn() // marks TTFD once real content is visible
        }
    }
}

Calling reportFullyDrawn() directly from inside a composable body would be a mistake, since a composable can recompose many times and you would fire it repeatedly for no reason. Compose instead gives you three purpose-built helpers that call through to reportFullyDrawn() at the right moment: ReportDrawnWhen fires once a condition you pass in becomes true, ReportDrawnAfter runs a suspend block first and reports when it completes, and ReportDrawn reports unconditionally on first composition. For a screen with several independent async loads that all need to finish first, FullyDrawnReporter exposes addReporter and removeReporter so you can require multiple conditions before TTFD fires.

@Composable
fun HomeScreen(items: List<Item>?) {
    if (items == null) {
        CircularProgressIndicator()
    } else {
        LazyColumn { items(items) { ItemRow(it) } }
    }
    // calls reportFullyDrawn() once true, not on every recomposition
    ReportDrawnWhen { items != null }
}

Remember the content provider problem from earlier: every library that auto-initializes via its own provider adds a real instantiation cost, with no defined order between them. The AndroidX App Startup library fixes this by replacing all of those separate providers with exactly one, InitializationProvider, which you declare once in the manifest. Instead of each library shipping its own provider, you register Initializer components as meta-data entries under that single provider, and App Startup runs them for you. The win is twofold: one provider instantiation instead of many, and a documented API for expressing what has to run before what, instead of relying on whatever order the OS happens to instantiate providers in.

<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false">
    <meta-data
        android:name="com.example.AnalyticsInitializer"
        android:value="androidx.startup" />
</provider>

Each Initializer has two methods, and it is easy to mix up what they are for. create() does the actual initialization work and returns the instance. dependencies() is the ordering mechanism: it returns a list of other Initializer classes that App Startup guarantees will have already run before this one's create() is called. This is what replaced the undefined provider ordering from before: if your analytics initializer needs networking set up first, you list NetworkInitializer::class.java in dependencies() and App Startup resolves the graph for you.

class AnalyticsInitializer : Initializer<AnalyticsSdk> {
    override fun create(context: Context): AnalyticsSdk {
        return AnalyticsSdk.init(context)
    }

    // NetworkInitializer.create() is guaranteed to run before this one
    override fun dependencies(): List<Class<out Initializer<*>>> =
        listOf(NetworkInitializer::class.java)
}

Not every initializer needs to run on every single cold start. If one is expensive and only occasionally needed, you can opt it out of the automatic pass entirely: remove its meta-data entry with tools:node="remove" so InitializationProvider never touches it, then trigger it yourself later by calling AppInitializer.getInstance(context).initializeComponent(), which also runs its declared dependencies. This is the same lazy-initialization instinct that applies everywhere in startup work: anything that is not required to get the first frame on screen is a candidate to push later, either to just after launch or to the moment the feature that actually needs it is used.

// AndroidManifest.xml excludes it from auto-init:
// <meta-data android:name="com.example.HeavyInitializer"
//            tools:node="remove" />

// Initialize it lazily, only when actually needed:
AppInitializer.getInstance(context)
    .initializeComponent(HeavyInitializer::class.java)

Baseline profiles attack a different layer of the problem: not how much work runs before the first frame, but how fast the code itself executes the first time it runs. Without one, ART interprets your app's bytecode, or JIT-compiles it, the first time each method executes, which is slower than running compiled native code. A baseline profile is simply a list of the hot classes and methods your app touches during a typical cold start and key journeys, and you generate it by driving those paths with BaselineProfileRule in a test. The generated baseline-prof.txt ships inside your APK or AAB, and at install time ProfileInstaller picks it up and hands it to ART, which then ahead-of-time compiles exactly those listed methods and classes, so the critical startup path runs as compiled native code from the very first launch instead of being interpreted or JIT-warmed on the fly.

@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(packageName = "com.example.app") {
        pressHome()
        startActivityAndWait() // walks the cold-start path; these methods get AOT-compiled
    }
}

Before Android 12, the common way to add a launch splash was a dedicated SplashActivity that showed a logo and then finished itself, but that approach actually hurts startup: it inserts an extra activity, with its own inflation and draw, directly onto the cold-start critical path you are trying to shrink. The SplashScreen API, standard on Android 12 and up and backported to earlier versions through the AndroidX core-splashscreen library, fixes this by theming the starting window the system was already going to show, rather than adding a new screen on top of it. You wire it up with a single call, installSplashScreen(), and the one gotcha that trips people up is that it must run before super.onCreate(), not after.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen() // must come BEFORE super.onCreate()
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

All of these fixes are hard to evaluate without a measurement you can trust, and a single manual launch watched in logcat is exactly the kind of number that is too noisy to trust: device state, background load, and thermal throttling all shift it launch to launch. The Macrobenchmark library exists for this. You set startupMode = StartupMode.COLD inside MacrobenchmarkRule, which kills and relaunches the app's process before every iteration, so you are actually measuring a cold start each time, not a warm one by accident. Pairing that with StartupTimingMetric as the metric gives you timeToInitialDisplay and timeToFullDisplay as stable distributions across many iterations on a real device, which is what you would actually cite as a before-and-after number when someone asks whether a baseline profile or a lazy-initialized SDK actually helped.

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule val rule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = rule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        startupMode = StartupMode.COLD, // kills process before every iteration
        iterations = 10
    ) {
        pressHome()
        startActivityAndWait()
    }
}

Back to App Startup Time