---
title: 'Startup Time'
source: 'https://academia.sh/en/courses/testing-performance-and-release/startup-time'
course: 'Testing, Performance and Release'
language: en
updated: '2026-10-21T05:02:43+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguishes cold, warm and hot starts by the work done before the first frame'
    - 'Classifies in how many of eight launches the first-frame budget is exceeded with one of four overruns, such as minority or majority'
    - 'Shows that the same function can yield two different overruns under two different budgets'
    - 'Measures that deferring a heavy start past the first frame does not remove the work but moves it to another budget'
---

# Startup Time

Cold, warm and hot starts do different amounts of work before the first frame. The lesson measures in how many of eight launches the first-frame budget is exceeded, and shows that one piece of work yields two overruns under two budgets and that deferred work does not disappear.

In the previous lesson static analysis looked at the code without running it; the baseline file removed the old violations from the count, and only the modules with new violations exceeded the budget. In this lesson we look at the code while it runs. The app opens on the user's device and does a certain amount of work until it draws the first frame. The question is whether that work fits a budget.

The course's measure does not change: in how many samples a measurement exceeds its budget. There are four answers. If no sample exceeds the budget the overrun is **within**; if fewer than half exceed it, **minority**; if half or more exceed it but not all, **majority**; if all exceed it, **all**. A sample equal to the limit is not an overrun. In the core, eight samples produced each overrun once; in this lesson the same classification is applied to eight launches.

```kotlin
// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)

class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
    val total: Int = samples.size
    val over: Int = samples.count { it > budget.limit }
    val verdict: String = when {
        over == 0 -> "within"
        over == total -> "all"
        over * 2 < total -> "minority"
        else -> "majority"
    }

    fun line() {
        println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
    }
}

fun countOverruns(overruns: List<Overrun>) {
    for (name in listOf("within", "minority", "majority", "all")) {
        println("$name: ${overruns.count { it.verdict == name }}")
    }
}
```

`Budget` carries a measurement's name and limit. `Overrun` compares the samples with the limit and counts the ones that exceed it. `countOverruns` counts the four overruns in a fixed order. This file is the same in every lesson of the course and never changes again.

## Three Kinds of Start

The app can open in one of three states. A **cold start** is when the process does not exist yet: the platform sets up the process, the app's startup code runs, the first screen is created and the first frame is drawn. A **warm start** is a subset of the work in a cold start: in most cases the process is still in memory and only the screen has to be recreated; if the platform has evicted the process from memory, the process is rebuilt too, but the screen benefits from its saved state. A **hot start** is when the screen sits in memory and is merely brought to the front; object and UI setup are mostly not repeated, and only the screen is redrawn.

The first frame is the first drawing the user sees from the app. Startup time is the name for the time between the launch request and this frame. In this lesson we do not measure time. What we will measure is the number of steps of work done up to the first frame, and that number comes from inside the model.

```kotlin
// Startup.kt — computes the work done before the first frame, in steps, by start kind
class Phase(val name: String, val steps: Int)

fun startupPhases(kind: String): List<Phase> = when (kind) {
    "cold" -> listOf(
        Phase("process", 2),
        Phase("app", 1),
        Phase("dependency", 4),
        Phase("screen", 2),
        Phase("draw", 1),
    )
    "warm" -> listOf(Phase("screen", 2), Phase("draw", 1))
    else -> listOf(Phase("draw", 1))
}

fun startupWork(kind: String): Int = startupPhases(kind).sumOf { it.steps }
```

`Phase` names one phase on the way to the first frame. `startupPhases` returns the phases for a start kind; `startupWork` adds up the steps of the phases. We look at what the three kinds look like.

```kotlin
// Main.kt — lists the work the three start kinds do before the first frame
fun main() {
    for (kind in listOf("cold", "warm", "hot")) {
        val parts = startupPhases(kind).joinToString(" ") { "${it.name}=${it.steps}" }
        println("$kind: $parts | total=${startupWork(kind)}")
    }
}
```

```text
cold: process=2 app=1 dependency=4 screen=2 draw=1 | total=10
warm: screen=2 draw=1 | total=3
hot: draw=1 | total=1
```

**PD1.** In the model a step is a number that shows how heavy a phase is; it is neither a second nor a frame. The steps of the phases are a lesson decision: dependency initialization is set up as the heaviest phase in a cold start. In a real app a measurement tells you which phase is heavy; the model only shows how the rule works.

The cold start's total is more than three times the warm start's, and the reason is visible: the process, app and dependency phases do not exist at all in a warm start. That is why someone looking for the source of startup work looks at the cold start first. On the platform, the following pieces are used to trace these phases and to report that the first interaction has finished.

```kotlin
// Platform code: tracing startup work and reporting the end of the first interaction
import android.app.Application
import android.os.Bundle
import android.os.Trace
import androidx.appcompat.app.AppCompatActivity

class SampleApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Trace.beginSection("app-startup")
        try {
            prepareCore()
        } finally {
            Trace.endSection()
        }
    }
}

class HomeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.home)
    }

    // called when the first data load completes
    private fun showFirstContent(items: List<String>) {
        render(items)
        reportFullyDrawn()
    }
}
```

This piece is platform code, and here we claim no output, duration or file path. The `Trace` calls show a section of the startup code as a named slice in the IDE's profiler; `reportFullyDrawn` tells the platform the moment the app is ready for use. The model sets up the rule of this measurement; the model is not the tool itself. In addition, part of the cold start, namely getting the compiled code ready, can be brought forward with a file called a baseline profile. The gain from a baseline profile depends on the app and the device and is not quoted as a number here.

## Eight Launches

Now we set a budget. Let the first-frame budget be six steps. The kinds of the eight launches come from a fixed list: two cold, two warm, four hot. The work of each launch is computed with `startupWork`, so the samples are the return values of a model function.

```kotlin
// Main.kt — eight launches are measured against the first-frame budget
fun main() {
    val launches = listOf("cold", "hot", "warm", "hot", "hot", "warm", "cold", "hot")
    val samples = launches.map { startupWork(it) }
    println("work: $samples")

    val overrun = Overrun("launch", Budget("first-frame", 6), samples)
    overrun.line()
    countOverruns(listOf(overrun))
}
```

```text
work: [10, 1, 3, 1, 1, 3, 10, 1]
launch: budget=first-frame<=6 | over=2/8 -> minority
within: 0
minority: 1
majority: 0
all: 0
```

Two of the eight launches, the cold ones, exceeded six steps: 2/8, so **minority**. The six warm and hot launches stayed well below the budget. This result does not say that an average launch is good; the average is not computed here. What it says is that the samples exceeding the budget are in the minority. If most of an app's launches are warm or hot this number stays small, and yet the user's first encounter, the cold start, may be exceeding the budget every time.

**PD2.** The list having two cold, two warm and four hot launches is an assumption of the model. In real use the ratio of the kinds varies with the user, the device's memory pressure and how often the app is closed, and that ratio is not presented here as measured data. The rule does not know the ratio of the kinds; it only counts the samples it is given.

There are two things the rule does not see. The first is the size of the overrun: whether the cold start exceeds the budget by one step or by four, `over` stays the same. The second is the cause of the overrun: the cold start's total is ten, but the rule does not know whether this comes from the dependency phase or from process setup. The cause lies in the output of `startupPhases`, not in the line of the rule. The rule counts in how many samples the budget is exceeded, not where the cost is paid.

## Same Work, Two Budgets

We apply three different budgets to the same samples. A budget is not a property of the function but a question put to it; when the strictness of the question changes, the same function yields a different overrun.

```kotlin
// Main.kt — the same eight launches are measured with three budgets
fun main() {
    val launches = listOf("cold", "hot", "warm", "hot", "hot", "warm", "cold", "hot")
    val samples = launches.map { startupWork(it) }

    val overruns = listOf(
        Overrun("wide", Budget("first-frame", 10), samples),
        Overrun("measured", Budget("first-frame", 6), samples),
        Overrun("tight", Budget("first-frame", 2), samples),
    )
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
```

```text
wide: budget=first-frame<=10 | over=0/8 -> within
measured: budget=first-frame<=6 | over=2/8 -> minority
tight: budget=first-frame<=2 | over=4/8 -> majority
within: 1
minority: 1
majority: 1
all: 0
```

The budget of ten did not exceed any sample, because the heaviest launch was exactly ten steps and a sample equal to the limit is not an overrun. The budget of six exceeded two samples, and the budget of two exceeded four. Under the budget of two, the warm launches also became overruns: the three-step warm start passed the two-step limit. Four overrunning samples are exactly half of eight, and half counts as **majority**; with one sample fewer the result would have been minority.

**PD3.** The lesson does not say which of the three budgets is right. Setting the budget is a product decision, and the rule counts only according to that decision. What matters here is this: the launch code did not change at all, only the question changed, and the overrun moved from within to majority. That is why an overrun alone does not show a good or a bad app; it is read together with the budget and the samples it was counted with.

## How to Choose a Budget

In this lesson we tried three budgets and declared none of them right. The reason is that a budget is not a measurement result but a product decision. The person who sets the budget assumes the longest the user is willing to wait after opening the app; the rule applies that assumption to the samples. If the budget is too wide, every launch looks within and a regression goes unnoticed. If the budget is too tight, every launch exceeds it and the report stops saying anything, because everything is red.

A good budget draws on two sources. The first is the app's own history: the measurement of the released version is a ceiling for later versions. The second is the start kind. Because cold, warm and hot starts do not do the same work, judging all of them with a single budget misleads. Setting a separate budget per kind, or pinning the measurement to a single kind, makes it more readable where an overrun comes from.

A budget also concerns the moment being measured. In this lesson the first-frame budget and the first-interaction budget are separate questions: one looks at the moment the user sees the screen, the other at the moment the screen can respond to a touch. Merging the two into one number hides where the deferred work is paid.

Another trap in startup measurement is the point where the measurement starts. The user starts the clock the moment they touch the icon; the app's own code runs after the process has been set up. A measurement taken from inside the app therefore does not see the platform's share of process setup. In this lesson the model's `process` phase keeps that share as a separate line; in a real measurement, numbers are not compared until it is written down which point they started from.

Someone improving startup often focuses on the cold start, because most of the work is done there. But the warm start may be the case the user experiences most often during the day. Tracking the two kinds with separate budgets makes it possible to notice when one improves while the other goes backward.

For the same reason a startup budget carries in its name which kind is targeted. "First-frame budget" is a vague name; "cold start, first frame, six steps" says who counts what against what. A budget whose name is not specific turns, years later, into a number nobody can interpret.

## Common Misconceptions

**"If the hot start is measured, startup has been measured."** In a hot start the process and the app object are already in memory; the work done is mainly bringing the screen to the front. Measuring this kind leaves most of the startup work out. In the model too the hot launch is a single step and exceeded no budget; this does not show that the app's startup code is good.

**"Deferring work past the first frame removes the work."** The deferred work is still done; the user just does not have to wait for it until they see the first frame. That is why the first-interaction budget is a separate measurement. Deferral moves the work from one budget to another; a gain cannot be declared without reading the two budgets together.

**"A single measurement is enough."** The same launch can do different work under different conditions. That is why the lesson set up a series of eight launches and ran the rule over the number of samples. With a single sample the overrun can only be within or all; the intermediate categories appear only with several samples.

**"The number of overruns tells the quality of the app."** The number carries meaning only together with the chosen budget and the chosen samples. The same app produced three different overruns under three budgets; the app did not change at all.

## The Bounding Measurement

The heaviest phase of the cold start was dependency initialization. A common fix is to defer this work past the first frame: the first frame is drawn, the user sees the screen, and the dependency is prepared in the background. In the model we set this up with two functions. One returns the work remaining until the first frame, the other the deferred work.

```kotlin
// Main.kt — the heavy dependency is deferred past the first frame; the work moves to the first interaction
fun beforeFirstFrame(kind: String): Int =
    startupPhases(kind).filter { it.name != "dependency" }.sumOf { it.steps }

fun afterFirstFrame(kind: String): Int =
    startupPhases(kind).filter { it.name == "dependency" }.sumOf { it.steps }

fun main() {
    val launches = listOf("cold", "hot", "warm", "hot", "hot", "warm", "cold", "hot")
    val first = launches.map { beforeFirstFrame(it) }
    val later = launches.map { afterFirstFrame(it) }
    println("first frame: $first")
    println("first interaction: $later")

    val overruns = listOf(
        Overrun("frame-work", Budget("first-frame", 6), first),
        Overrun("interaction-work", Budget("first-interaction", 3), later),
    )
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
```

```text
first frame: [6, 1, 3, 1, 1, 3, 6, 1]
first interaction: [4, 0, 0, 0, 0, 0, 4, 0]
frame-work: budget=first-frame<=6 | over=0/8 -> within
interaction-work: budget=first-interaction<=3 | over=2/8 -> minority
within: 1
minority: 1
majority: 0
all: 0
```

The first-frame budget is now **within**: the cold start dropped to six steps, and because six equals the limit it was not counted as an overrun. But the work did not disappear. The four-step dependency initialization was left to the first interaction, and that separate budget, with its limit of three steps, was again exceeded in two samples: **minority**. The deferral took the cost off the first-frame line and wrote it onto the interaction line.

**PD4.** The first-interaction budget being three steps is a lesson decision. How much work the user's first touch can bear depends on what the app does. It is also not claimed that deferral is always bad: the user may look at the screen for much longer before the first interaction, and the deferred work finishes in that time. The model does not set up this parallelism. The only thing it sets up is that the deferred work is counted again in another budget.

Here too the rule sees how many samples overrun, not by how much or why. The improvement on the first-frame line is real, but that line alone does not give the whole picture. Moving the work elsewhere to reduce startup work and actually removing the work are different things; the rule separates them only when the two budgets are put side by side.

## Summary

- This lesson's overrun: minority. Two of the eight launches, the cold ones, exceeded the six-step first-frame budget.
- A cold start carries the process, app, dependency, screen and draw phases; a warm start does the screen and the draw, and a hot start only the draw.
- The same eight samples came out within under the ten-step budget, minority under six steps, and majority under two steps. An overrun is a property not of the function but of the function together with the budget.
- A sample equal to the limit is not an overrun: a six-step first-frame workload does not exceed a six-step budget.
- Deferring the dependency initialization past the first frame made the first frame within; the work did not disappear, it reappeared as minority in a separate first-interaction budget.

## Next Step

The first frame is drawn, but the app's work is not over. When the user scrolls a list, every frame is redrawn and every frame has its own budget. The next lesson, `Rendering Smoothness`, measures in how many frames the frame budget is exceeded when every frame recomposes all the items of a scrolled list.
