SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:46
UP00:01
MOD · ARTICLE · PERFORMANCES/N · AX-ANDROIPUBLISHED
All Articles
Performance · Updated 10 MIN

Android App Startup Optimization: What to Measure and What to Fix First

Measure cold start with Macrobenchmark, then fix it with Baseline Profiles, lazy initialization and App Startup, against the thresholds Google Play uses.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

What counts as a slow start on Android?

An app starts in one of three states, and the Macrobenchmark reference defines them in one line each. Cold is "Startup from scratch - app's process is not alive, and must be started in addition to Activity creation." Hot is "Bring existing activity to the foreground, process and Activity still exist from previous launch." Warm sits between them: the process is alive and a new Activity has to be created. Optimize for cold. Android's startup guide is explicit: "We recommend that you always optimize based on an assumption of a cold start. Doing so can improve the performance of warm and hot starts as well." The earlier version of this article carried "under 500ms" in its title and said Google considers a cold start acceptable below that. The page it linked says no such thing, and the title has changed. The numbers Google does publish are the ones Android vitals uses to count a start as slow (Play Console Help):
Start typeCounted as slow at
Cold5 seconds or more
Warm2 seconds or more
Hot1 second or more
Those are ceilings, not targets. A cold start of 4.9 seconds passes and is still a bad one. How vitals thresholds affect your listing is covered in the Android vitals guide.

Measuring Startup with Macrobenchmark

Two metrics describe a start, and the guide defines both: "TTID is the time it takes to display the first frame, and TTFD is the time it takes for the app to become fully interactive." The earlier version of this article said to measure TTID first because it is what users perceive. The guide disagrees: "Both are equally important, because TTID lets the user know that the app is loading, and TTFD is when the app is actually usable." They are not equally easy to get. "TTID is reported automatically for every app by the Android Framework." TTFD exists only if you tell the system when the app is usable: "we recommend implementing reportFullyDrawn to get information up to TTFD". If the first screen fills a list from the network, call it after the list has content, not before. Macrobenchmark measures both from outside the app, on a release-like build. StartupTimingMetric reports timeToInitialDisplayMs and, when the app reports fully drawn, timeToFullDisplayMs.
kotlin
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        startupMode = StartupMode.COLD,
        iterations = 10,
    ) {
        pressHome()
        startActivityAndWait()
    }
}

// In the first screen, once it is usable:
class MainActivity : ComponentActivity() {
    private fun onContentReady() {
        reportFullyDrawn() // Without this there is no TTFD
    }
}

Baseline Profiles: Compile the Startup Path Before First Launch

On a fresh install, your startup code is interpreted and then compiled just in time while the user waits. A Baseline Profile ships a list of the code paths to compile ahead of time. Android's figure: "Baseline Profiles improve code execution speed by about 30% from the first launch by avoiding interpretation and just-in-time (JIT) compilation steps for included code paths." The earlier version of this article quoted "20-40% faster cold starts" and attributed it to Google. The documented number is the one above, about 30% in code execution speed, and it is a general figure. Run the benchmark from the previous section with and without the profile to get yours. For startup specifically, the same page adds a second artifact: "We recommend using both Baseline Profiles and startup profiles to fully optimize app startup." Setting up the generator module, the plugin and the startup profile flag is in the Baseline Profiles and R8 guide. The app side is four lines.
kotlin
// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("androidx.baselineprofile")
}

dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
    baselineProfile(project(":baseline-profile"))
}

Lazy Initialization with the App Startup Library

Many libraries initialize themselves through a content provider, which runs before your own code. The App Startup guide states the cost: "content providers are expensive to instantiate and can slow down the startup sequence unnecessarily." Its answer is one shared provider: "Instead of defining separate content providers for each component you need to initialize, App Startup allows you to define component initializers that share a single content provider." The earlier version of this article put numbers on that, 2 to 10 ms per provider and 10 to 20 providers per app. Neither had a source. Both are removed. Sharing a provider makes initialization cheaper. Not doing it at startup makes it free. The guide calls this lazy initialization and names the step that the old sample skipped: "You must first disable automatic initialization for any components that you want to initialize manually." You do that in the manifest, with tools:node="remove" on the initializer's entry, and then call AppInitializer at the point of first use. The old sample instead posted the call to the main thread's handler from Application.onCreate() and described that as running after the first frame. It left the component registered for automatic startup, so nothing was deferred. The current release is androidx.startup:startup-runtime:1.2.0.
kotlin
// An initializer: what to create, and what it depends on
class CrashReportingInitializer : Initializer<CrashReporter> {

    override fun create(context: Context): CrashReporter {
        return CrashReporter.init(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        // Initialized first, automatically
        return listOf(AnalyticsInitializer::class.java)
    }
}

// AndroidManifest.xml: take it out of automatic startup
//
// <provider
//     android:name="androidx.startup.InitializationProvider"
//     android:authorities="${applicationId}.androidx-startup"
//     android:exported="false"
//     tools:node="merge">
//     <meta-data
//         android:name="com.example.CrashReportingInitializer"
//         tools:node="remove" />
// </provider>

// At the first place the component is needed
AppInitializer.getInstance(context)
    .initializeComponent(CrashReportingInitializer::class.java)

Reducing Work Before the First Frame

Whatever runs before the first frame is counted in TTID. The startup guide names the two places it accumulates. The first is the Application object: "Launch performance can suffer when your code overrides the Application object and executes heavy work or complex logic when initializing that object." The second is the first Activity: "Activity creation often entails a lot of high-overhead work." The guide lists a large or complex UI and heavy initialization in composables among the causes. The pattern below keeps the first frame cheap: draw the frame of the screen at once, show a placeholder for data that is not there yet, and fill it in when it arrives. It also corrects a bug in the earlier sample, which read properties of a delegated state value inside a when without binding it first. Why a screen recomposes more than it should after that first frame is a separate subject, in the Compose performance guide.
kotlin
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Column {
        // Draws on the first frame, needs no data
        TopBar(title = "Home")

        when (val state = uiState) {
            is HomeState.Loading -> LoadingPlaceholder()
            is HomeState.Success -> {
                LazyColumn {
                    items(state.items) { item ->
                        ContentCard(item)
                    }
                }
            }
            is HomeState.Error -> ErrorBanner(state.message)
        }
    }
}

Diagnosing Bottlenecks with a Trace

When the benchmark says the start is slow and the cause is not obvious, record a trace. Android's definition: "Recording device activity over a short period of time is known as system tracing" (Android). The same page lists the ways to capture one, including the Android Studio profiler and the Perfetto command-line tool on Android 10 and higher. Read the trace for the main thread between process start and the first frame. Long blocks there are your startup time. The startup guide also suggests adding your own markers: "You can also gain insight into potential bottlenecks through inline tracing inside of your apps' and activities' onCreate methods." During development, let the platform catch the most common offender for you. "StrictMode is most commonly used to catch accidental disk or network access on the application's main thread, where UI operations are received and animations take place" (Android). A disk read in Application.onCreate() is exactly that.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Android vitals counts a cold start as slow at 5 seconds or more, a warm start at 2 and a hot start at 1. Those are ceilings, not targets.
  2. 2TTID and TTFD are equally important. TTFD only exists if the app calls reportFullyDrawn().
  3. 3Measure with Macrobenchmark before and after every change, on a release-like build.
  4. 4Baseline Profiles improve code execution speed by about 30% from the first launch, per Android. Add a startup profile as well.
  5. 5With App Startup, remove an initializer from the manifest before initializing it lazily, or nothing is deferred.
  6. 6Record a system trace and read the main thread up to the first frame. Use StrictMode to catch disk and network access there.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

What is a good cold start time on Android?

Google publishes a ceiling, not a target. Android vitals counts a cold start as slow at 5 seconds or more, a warm start at 2 seconds or more and a hot start at 1 second or more. Treat those as the point of failure, set your own budget well below them, and measure it with Macrobenchmark.

How do Baseline Profiles help startup?

They list the code paths to compile ahead of time, so startup code is not interpreted and JIT-compiled while the user waits. Android states that Baseline Profiles improve code execution speed by about 30% from the first launch for included code paths, and recommends adding a startup profile to fully optimize app startup.

What is the difference between TTID and TTFD?

Time to initial display is the time it takes to display the first frame, and the Android Framework reports it for every app. Time to full display is the time until the app is fully interactive, and it is only recorded when the app calls reportFullyDrawn(). Android describes the two as equally important.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA