Your app launches in 3 seconds. Users see a white screen. They close the app. Your retention drops.
Performance is not optional. Google Play ranks apps by performance metrics. Users uninstall apps that feel slow. The difference between a 1-second and 3-second startup can be a 50% drop in engagement.
In this tutorial — the final one in the series — you will learn how to measure and optimize your app’s performance using Baseline Profiles, Macrobenchmark, and modern profiling tools.
Prerequisites: You should have a release-ready app. See Android Tutorial #16: App Security for R8 configuration.
Why Performance Matters
Google tracks your app’s performance in Android Vitals:
| Metric | Bad Threshold | Impact |
|---|---|---|
| Startup time (cold) | > 5 seconds | Lower search ranking |
| ANR rate | > 0.47% | Warning in Play Console |
| Crash rate | > 1.09% | Warning in Play Console |
| Excessive wakeups | > 10/hour | Battery warning |
Apps that exceed these thresholds get warnings in the Play Console and may be ranked lower in search results.
App Startup Types
Android has three types of app starts:
Cold Start
The app is not in memory. The system creates the process, initializes the Application class, creates the activity, inflates the layout, and draws the first frame.
This is the slowest start. Optimize it first.
Warm Start
The app is in memory but the activity was destroyed. The system recreates the activity but skips process initialization.
Hot Start
The app and activity are in memory. The system just brings the activity to the foreground. This is nearly instant.
Measuring Startup Time
reportFullyDrawn()
Tell the system when your app is ready to use:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp(
onReady = {
// Report when the first meaningful content is shown
reportFullyDrawn()
}
)
}
}
}
@Composable
fun MyApp(onReady: () -> Unit) {
val viewModel: HomeViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.isLoading) {
if (!state.isLoading && state.items.isNotEmpty()) {
onReady()
}
}
// App content
}
This shows up in Android Vitals as your “Time to fully drawn” metric.
Tracing API
Add custom trace sections to understand where time is spent:
class MyApp : Application() {
override fun onCreate() {
Trace.beginSection("App.onCreate")
super.onCreate()
Trace.beginSection("Hilt.init")
// Hilt initialization happens here
Trace.endSection()
Trace.beginSection("Channels.create")
NotificationChannels.createAll(this)
Trace.endSection()
Trace.endSection() // App.onCreate
}
}
View traces in Android Studio Profiler or in system trace files.
Logcat Timing
Quick check without tools:
# Filter for startup metrics
adb logcat -s ActivityTaskManager | grep "Displayed"
# Output: Displayed com.example.myapp/.MainActivity: +1s234ms
Baseline Profiles
Baseline Profiles tell the Android runtime (ART) which code paths to compile ahead of time. This improves startup by 30% and reduces jank in critical user journeys.
How They Work
Without Baseline Profiles, ART interprets your code at first launch. Over time, it JIT-compiles hot methods. After a few hours, it AOT-compiles them during idle charging.
With Baseline Profiles, ART AOT-compiles the specified methods at install time. First launch is already optimized.
Setting Up Macrobenchmark
Create a separate module for benchmarks:
// settings.gradle.kts
include(":benchmark")
// benchmark/build.gradle.kts
plugins {
id("com.android.test")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 28
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
testOptions.managedDevices.localDevices {
create("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
}
dependencies {
implementation("androidx.test.ext:junit:1.2.1")
implementation("androidx.benchmark:benchmark-macro-junit4:1.3.4")
}
Generating Baseline Profiles
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()
@Test
fun startup() = baselineProfileRule.collect(
packageName = "com.example.myapp"
) {
// Cold start
pressHome()
startActivityAndWait()
// Navigate through critical user journeys
device.findObject(By.text("Tasks")).click()
device.waitForIdle()
device.findObject(By.text("Settings")).click()
device.waitForIdle()
}
}
Run the generator:
./gradlew :benchmark:pixel6Api34BenchmarkAndroidTest \
-P android.testInstrumentationRunnerArguments.class=com.example.benchmark.BaselineProfileGenerator
Adding to Your App
Copy the generated profile to app/src/main/baseline-prof.txt (the Baseline Profile Gradle plugin can automate this).
Or use the plugin:
// build.gradle.kts (app)
plugins {
id("androidx.baselineprofile")
}
dependencies {
baselineProfile(project(":benchmark"))
}
Then generate and install:
./gradlew :app:generateBaselineProfile
Measuring the Impact
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startupWithoutBaseline() = benchmarkRule.measureRepeated(
packageName = "com.example.myapp",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
compilationMode = CompilationMode.None()
) {
pressHome()
startActivityAndWait()
}
@Test
fun startupWithBaseline() = benchmarkRule.measureRepeated(
packageName = "com.example.myapp",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
)
) {
pressHome()
startActivityAndWait()
}
}
Typical results:
- Without Baseline Profile: 1200ms cold start
- With Baseline Profile: 800ms cold start (33% improvement)
Avoiding ANRs
An ANR (Application Not Responding) occurs when the main thread is blocked for more than 5 seconds.
Common ANR Causes
- Network calls on the main thread
- Heavy database queries without coroutines
- File I/O on the main thread
- Slow ContentProvider operations
- Lock contention between threads
StrictMode
Enable StrictMode in debug builds to catch main thread violations:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.penaltyDeath() // Crash on violation
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.detectActivityLeaks()
.penaltyLog()
.build()
)
}
}
}
Moving Work Off the Main Thread
// Bad — blocks main thread
fun loadSettings(): Settings {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
return Settings(
theme = prefs.getString("theme", "system")!!,
language = prefs.getString("language", "en")!!
)
}
// Good — uses coroutines
suspend fun loadSettings(): Settings = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
Settings(
theme = prefs.getString("theme", "system")!!,
language = prefs.getString("language", "en")!!
)
}
Compose Performance
Stable Types
Compose skips recomposition for composables with stable parameters. Make your state classes stable:
// Unstable — Compose always recomposes
data class TaskState(
val tasks: List<Task>, // List is unstable
val isLoading: Boolean
)
// Stable — Compose can skip recomposition
@Immutable
data class TaskState(
val tasks: ImmutableList<Task>, // kotlinx-collections-immutable
val isLoading: Boolean
)
Add the dependency:
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
Compose Compiler Reports
Generate reports to find non-skippable composables:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_reports")
}
./gradlew assembleRelease
# Check app/build/compose_reports/
The report shows:
- Which composables are skippable
- Which parameters are stable vs unstable
- Where recomposition cannot be skipped
Key Performance Rules for Compose
- Use
key()inLazyColumnitems - Avoid creating lambdas in the composition (use
remember) - Use
derivedStateOffor computed values - Don’t read state in places where you don’t need it
For more details, see Compose Tutorial #17: Performance.
Memory Leaks
LeakCanary
LeakCanary detects memory leaks automatically in debug builds:
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}
That’s it. No code needed. LeakCanary shows a notification when it detects a leak, with the full reference chain.
Common Android Memory Leaks
- Holding Activity reference in ViewModel — never pass Context to ViewModel
- Static references to Views — don’t store Views in companion objects
- Unregistered listeners — always unregister in
onDestroy/onDispose - Inner classes holding outer reference — use
WeakReferenceor extract to top-level class
Image Loading Optimization
Images are often the biggest performance bottleneck. Use Coil for efficient loading:
// With Coil
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.size(300, 300) // Don't load full-size image
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = null,
modifier = Modifier.size(150.dp)
)
Key optimizations:
- Size constraints — don’t load a 4000px image for a 150dp thumbnail
- Memory cache — avoid re-decoding images
- Disk cache — avoid re-downloading images
- Crossfade — smooth loading transition
16 KB Page Size Compatibility
Android 15 introduced support for 16 KB memory page sizes on compatible hardware. As of November 1, 2025, Google Play requires 16 KB page size compatibility for new apps and updates that use native code (NDK) and target Android 15+.
Check if your app is compatible:
# Check for 4KB-aligned native libraries
zipinfo -l app-release.aab | grep "\.so"
If you use NDK or native libraries, ensure they are built with 16 KB page alignment:
# CMakeLists.txt
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384")
Most pure-Kotlin apps are unaffected.
Profiling in Production
Android Vitals
Monitor your app’s real-world performance in Play Console:
- Go to Play Console → Your App → Android Vitals
- Check: Crash rate, ANR rate, startup time, permission denials
- Set up alerts for threshold breaches
Firebase Performance Monitoring
For custom performance tracking:
class NetworkRepository @Inject constructor(
private val api: ApiService
) {
suspend fun fetchData(): Result<Data> {
val trace = Firebase.performance.newTrace("fetch_data")
trace.start()
return try {
val result = api.getData()
trace.putAttribute("status", "success")
trace.putMetric("item_count", result.size.toLong())
Result.success(result)
} catch (e: Exception) {
trace.putAttribute("status", "error")
trace.putAttribute("error", e.message ?: "unknown")
Result.failure(e)
} finally {
trace.stop()
}
}
}
Cross-link: See Android Tutorial #17: Firebase for Firebase Performance Monitoring setup.
Performance Optimization Checklist
Before releasing:
- Baseline Profile generated and included
- Cold startup under 2 seconds
- No StrictMode violations
- No memory leaks detected by LeakCanary
- Compose Compiler reports checked for non-skippable composables
- Images loaded with size constraints and caching
- R8 enabled with resource shrinking
- ANR rate below 0.47% in testing
-
reportFullyDrawn()called when content is ready - 16 KB page size compatibility checked (if using native code)
Practical Example: Optimizing a Slow App
Here is a before/after example:
Before (3.2 second cold start)
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Initializing everything at startup
Firebase.initialize(this) // 200ms
NotificationChannels.createAll(this) // 50ms
loadFeatureFlags() // 800ms (network call!)
initializeAnalytics() // 100ms
preloadImages() // 400ms
}
}
After (1.1 second cold start)
@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Only essential initialization
NotificationChannels.createAll(this) // 50ms
}
}
// Firebase initializes lazily through Hilt
// Feature flags load in ViewModel (not blocking startup)
// Images load on demand (not preloaded)
// Analytics initializes on first event
Changes made:
- Moved network call out of Application.onCreate — feature flags load in the ViewModel
- Removed image preloading — Coil handles caching
- Lazy Firebase init — Hilt provides Firebase instances when first needed
- Added Baseline Profile — 30% startup improvement
Result: 3.2s to 1.1s cold start (66% improvement).
Common Mistakes
1. Not Measuring Before Optimizing
Don’t guess where the bottleneck is. Use Macrobenchmark, Android Studio Profiler, or system traces to find the actual problem.
2. Ignoring Baseline Profiles
Baseline Profiles are the highest-impact, lowest-effort optimization. They give 30% improvement for a few hours of setup.
3. Heavy Application.onCreate
Every millisecond in Application.onCreate adds to every cold start. Initialize lazily. Move network calls to ViewModels.
4. Not Using ImmutableList in Compose
Compose treats List<T> as unstable. Every recomposition rebuilds composables that take List parameters. Use ImmutableList from kotlinx-collections-immutable.
5. Ignoring Android Vitals
Android Vitals data is free and shows real-world performance. Check it weekly. Fix issues before they affect your store ranking.
Series Complete!
Congratulations! You completed all 20 tutorials in the Android Development series.
Here is what you learned:
Part 1: Architecture — MVVM, MVI, Clean Architecture, Hilt, Repository pattern, Use Cases, Multi-module
Part 2: Data & Networking — Retrofit, Room advanced, DataStore, WorkManager, Paging 3
Part 3: Android Platform — Notifications, Foreground Services, Content Providers, Deep Links, Glance Widgets
Part 4: Production — R8/ProGuard, Firebase, CI/CD, Play Store Publishing, Performance
You now have the knowledge to build, test, and ship production-ready Android apps. The next step is to build something real. Pick a project, apply these patterns, and publish it.
Related Articles
- Jetpack Compose Tutorial #17: Performance — Compose-specific optimizations
- Android Tutorial #16: App Security — R8 for release builds
- Android Tutorial #19: Play Store Publishing — Android Vitals monitoring
- Android Tutorial #17: Firebase — Firebase Performance Monitoring
This is part 20 of the Android Development Tutorial series.