Skip to content
All posts
September 4, 20267 min read

Reducing Cold Start Time in Android Apps: A Practical Guide

I break down the biggest cold start bottlenecks in Android apps and show you how to measure, profile, and eliminate them with real code examples.

AndroidKotlinPerformanceBest Practices
Share:

Cold start latency kills user retention before your first screen even renders. Every 100 milliseconds of delay increases abandonment risk. I have shipped 22 apps from Bangkok and learned that startup optimization is not a one-time task — it is a discipline you bake into every release.

The difference between a 1.2-second launch and a 2.8-second launch is often the difference between a 4.5-star rating and a 3.2-star rating. Users do not care about your architecture. They care that the app opens now.

Understanding Cold Start Phases

A cold start happens when the system creates your process from scratch. The OS loads your application class, attaches the base context, runs

code
onCreate()
, inflates the launch theme, creates the first activity, and draws the first frame. Each phase compounds.

PhaseTypical DurationWhat Happens
Process Creation50–150 msZygote fork, memory allocation
Application.onCreate()100–500 msYour initialization code runs
Activity Creation50–200 ms
code
onCreate()
,
code
onStart()
,
code
onResume()
View Inflation & Layout100–400 msXML parsing, measure, layout, draw
First Frame Render16–50 msGPU composition, surface presentation

[!NOTE] The launch theme window background renders before your activity code runs. A well-designed launch theme buys you perceived speed while real initialization happens in the background.

Your

code
Application.onCreate()
is the single biggest lever. Everything you put there blocks the main thread before
code
setContentView()
is ever called. I have seen apps initialize analytics, crash reporters, database migrations, network clients, and image loaders — all sequentially — before the first activity starts.

Measuring and Profiling Startup Performance

You cannot optimize what you do not measure. Android provides three complementary tools that I use on every project.

Macrobenchmark gives you production-representative numbers. It launches your app in a separate process, measures time-to-initial-display (TTID) and time-to-full-display (TTFD), and outputs JSON you can track in CI.

kotlin
// app/src/androidTest/kotlin/com/sudarshantechlabs/startup/StartupBenchmark.kt
package com.sudarshantechlabs.startup

import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.benchmark.macro.perfetto.PerfettoTrace
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartup_timeToInitialDisplay() {
        benchmarkRule.measureRepeated(
            packageName = "com.sudarshantechlabs.myapp",
            metrics = listOf(StartupTimingMetric()),
            iterations = 10,
            startupMode = StartupMode.COLD,
            setupBlock = { pressHome() }
        ) { 
            startActivityAndWait()
        }
    }

    private fun pressHome() {
        UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
            .pressHome()
    }

    private fun startActivityAndWait() {
        val intent = Intent().apply {
            setPackage("com.sudarshantechlabs.myapp")
            setAction("android.intent.action.MAIN")
            addCategory("android.intent.category.LAUNCHER")
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        device.executeShellCommand("am start -W -S $intent")
        device.wait(Until.hasObject(By.pkg("com.sudarshantechlabs.myapp").depth(0)), 5000)
    }
}

Perfetto traces show you exactly what runs on each thread during startup. Open the trace in

code
ui.perfetto.dev
, filter for your package, and look for main-thread blocks longer than 16 ms. The "Main" thread track reveals every
code
Application.onCreate()
call, every
code
ContentProvider
initialization, every layout inflation.

Baseline Profiles let ART pre-compile your hot paths. Since Android 12 (API 31), they are the highest-leverage optimization you can ship. A good baseline profile reduces cold start by 20–40% without changing a line of app logic.

kotlin
// baseline-profiles/src/main/kotlin/com/sudarshantechlabs/baseline/StartupProfileGenerator.kt
package com.sudarshantechlabs.baseline

import androidx.benchmark.macro.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class StartupProfileGenerator {

    @get:Rule
    val baselineProfileRule = BaselineProfileRule()

    @Test
    fun generate() {
        baselineProfileRule.collect(
            packageName = "com.sudarshantechlabs.myapp"
        ) {
            startActivityAndWait()
            
            // Scroll through key lists to capture RecyclerView layout code
            device.findObject(By.res("com.sudarshantechlabs.myapp:id/home_list"))
                .fling(Direction.DOWN)
            device.waitForIdle()
            
            // Navigate to second screen to capture fragment transitions
            device.findObject(By.text("Settings")).click()
            device.waitForIdle()
        }
    }
}

[!TIP] Generate baseline profiles for every release. Add the

code
baseline-profiles
Gradle module, run the generator on a physical device (not emulator), and ship the
code
baseline-prof.txt
in your AAB. The Play Store delivers it to users automatically.

Optimizing Application and Activity Initialization

The

code
Application
class is not a dumping ground. Treat it as a critical path. Everything in
code
onCreate()
must justify its presence.

Lazy initialization moves work off the startup path. Use

code
by lazy
for singletons,
code
lateinit
for dependencies you control, and
code
Provider<T>
from Hilt/Koin for framework-managed objects.

kotlin
// app/src/main/kotlin/com/sudarshantechlabs/myapp/MyApplication.kt
package com.sudarshantechlabs.myapp

import android.app.Application
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ProcessLifecycleOwner
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.sudarshantechlabs.myapp.di.AppContainer
import com.sudarshantechlabs.myapp.repository.UserRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob

class MyApplication : Application() {

    // Initialized on first access, not in onCreate()
    val appContainer by lazy { AppContainer(this) }
    
    // Coroutine scope tied to process lifecycle
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    override fun onCreate() {
        super.onCreate()
        
        // Only the absolute minimum runs here
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
        
        // Crashlytics must initialize early for crash capture
        FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(true)
        
        // Defer everything else to background
        applicationScope.launch(Dispatchers.IO) {
            initializeDeferredComponents()
        }
    }

    private suspend fun initializeDeferredComponents() {
        // Analytics, database, network clients, image loaders — all here
        val analytics = FirebaseAnalytics.getInstance(this@MyApplication)
        analytics.setAnalyticsCollectionEnabled(true)
        
        // Warm up repositories that need disk I/O
        appContainer.userRepository.warmUp()
        
        // Pre-load critical configs
        appContainer.configRepository.preload()
    }
}

[!WARNING] Never perform disk I/O, network calls, or complex calculations in

code
Application.onCreate()
on the main thread. The system kills your process with an ANR if you block longer than ~5 seconds. Move all non-essential work to
code
Dispatchers.IO
or
code
WorkManager
.

ContentProviders initialize before

code
Application.onCreate()
and run on the main thread. Each one adds 5–20 ms. Audit your manifest and remove unused providers. If you use Firebase, the
code
FirebaseInitProvider
is required — but you can disable auto-initialization for Analytics and Crashlytics and start them manually in your deferred block.

xml
<!-- app/src/main/AndroidManifest.xml -->
<provider
    android:name="com.google.firebase.provider.FirebaseInitProvider"
    android:authorities="${applicationId}.firebaseinitprovider"
    android:exported="false"
    android:initOrder="100" />
    
<!-- Disable auto-init for Analytics -->
<meta-data
    android:name="firebase_analytics_collection_enabled"
    android:value="false" />

<!-- Disable auto-init for Crashlytics -->
<meta-data
    android:name="firebase_crashlytics_collection_enabled"
    android:value="false" />

Activity launch optimization starts with your theme. Use a window background drawable that matches your first frame. Avoid

code
Theme.AppCompat.Light.NoActionBar
with a white background if your first screen is dark — the flash is jarring and adds perceived latency.

xml
<!-- res/drawable/launch_background.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/brand_dark_background" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/ic_launcher_foreground" />
    </item>
</layer-list>
xml
<!-- res/values/themes.xml -->
<style name="Theme.Launch" parent="Theme.AppCompat.NoActionBar">
    <item name="android:windowBackground">@drawable/launch_background</item>
    <item name="android:windowIsTranslucent">false</item>
    <item name="android:windowDisablePreview">true</item>
</style>

Apply

code
Theme.Launch
to your launcher activity in the manifest. The system shows this drawable instantly while your activity initializes.

Deferring Non-Critical Work with Coroutines and WorkManager

Not everything needs to run at startup. Feature flags, remote config, periodic sync, log uploads — these belong in

code
WorkManager
with
code
ExistingPeriodicWorkPolicy.KEEP
or
code
REPLACE
.

kotlin
// app/src/main/kotlin/com/sudarshantechlabs/myapp/worker/StartupDeferredWorker.kt
package com.sudarshantechlabs.myapp.worker

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequest
import com.sudarshantechlabs.myapp.repository.AnalyticsRepository
import com.sudarshantechlabs.myapp.repository.RemoteConfigRepository
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.TimeUnit

class StartupDeferredWorker(
    context: Context,
    params: WorkerParameters,
    private val analyticsRepository: AnalyticsRepository,
    private val remoteConfigRepository: RemoteConfigRepository
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            // Fetch remote config for feature flags
            remoteConfigRepository.fetchAndActivate()
            
            // Upload pending analytics events
            analyticsRepository.flushPendingEvents()
            
            // Check for app updates
            checkForUpdates()
            
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }

    private suspend fun checkForUpdates() {
        // In-app update check logic here
    }

    companion object {
        const val WORK_NAME = "StartupDeferredWork"
        const val INTERVAL_HOURS = 24L

        fun schedule(context: Context) {
            val workRequest = PeriodicWorkRequestBuilder<StartupDeferredWorker>(
                INTERVAL_HOURS, TimeUnit.HOURS
            )
                .setInitialDelay(5, TimeUnit.MINUTES) // Don't compete with startup
                .build()

            WorkManager.getInstance(context)
                .enqueueUniquePeriodicWork(
                    WORK_NAME,
                    ExistingPeriodicWorkPolicy.KEEP,
                    workRequest
                )
        }
    }
}

Schedule this worker from your deferred initialization block, not from

code
Application.onCreate()
directly.

kotlin
// In MyApplication.initializeDeferredComponents()
StartupDeferredWorker.schedule(this@MyApplication)

[!IMPORTANT] Report

code
reportFullyDrawn()
when your first meaningful content is visible. This tells the system your app is interactive, triggering ART profile compilation and improving subsequent launches. Call it after your RecyclerView adapter submits data, not in
code
onResume()
.

kotlin
// In your MainActivity or HomeFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    
    viewModel.items.observe(viewLifecycleOwner) { items ->
        adapter.submitData(items)
        // Report fully drawn AFTER data renders
        reportFullyDrawn()
    }
}

Key Takeaways

  • Measure first, optimize second. Add Macrobenchmark to CI, capture Perfetto traces on every release, and track TTID/TTFD trends over time. You will catch regressions before users do.
  • Strip
    code
    Application.onCreate()
    to the bone.
    Keep only crash reporting and theme setup. Defer analytics, database, network, and image loaders to a background coroutine scope tied to process lifecycle.
  • Ship Baseline Profiles with every release. They are the single highest-ROI optimization for cold start. Generate them on physical devices, include them in your AAB, and let the Play Store handle distribution.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this