On this page
Users rarely say "this app has a 1.8-second cold start and drops frames in the feed". They say "it feels slow", and some of them uninstall. Performance is a feature, and for Android apps that run on everything from flagship phones to budget devices it's one of the hardest to get right.
This guide is for Android developers who want a systematic approach rather than a list of random tips. It covers the measure-first workflow, the tools for each problem, and the fixes with the biggest real-world impact across startup, rendering, memory, responsiveness, battery and size.
The workflow: measure, fix, verify
Debug builds disable R8 optimisations and add debugging overhead. Jetpack Compose in particular runs noticeably slower in debug. Benchmark a release-like (minified, non-debuggable) build type.
| Problem | Field signal | Lab tool |
|---|---|---|
| Slow startup | Android vitals startup metrics | Macrobenchmark StartupTimingMetric, Perfetto |
| Janky scrolling and animation | Vitals rendering metrics, JankStats | Macrobenchmark FrameTimingMetric, Profiler |
| ANRs | Vitals ANR rate (a core vital) | StrictMode, Perfetto main-thread traces |
| Memory leaks and OOM | Crash reports (OutOfMemoryError) | LeakCanary, Memory Profiler, heap dumps |
| Battery drain | Vitals (excessive wake locks, background work) | Power Profiler, Background Task Inspector |
| Large download size | Play Console size reports | APK Analyzer, R8 configuration |
1. Startup time
Startup is the first impression, and it compounds because users open apps many times a day. Android distinguishes cold starts (process created from scratch), warm starts and hot starts. Cold start matters most, and it is where most improvements come from.
Measure it with Macrobenchmark
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test fun coldStartNoCompilation() = startup(CompilationMode.None())
@Test fun coldStartWithBaselineProfile() = startup(CompilationMode.Partial())
private fun startup(mode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
compilationMode = mode,
startupMode = StartupMode.COLD,
iterations = 10,
) {
pressHome()
startActivityAndWait()
}
}The highest-impact startup fixes
- Do less in
Application.onCreate(). Initialise SDKs lazily or after the first frame. Analytics, remote config and image loaders rarely need to block startup. - Add Baseline Profiles (next section). They help the runtime pre-compile critical code paths and are one of the most effective startup and jank improvements.
- Avoid disk and network on the main thread during startup: no synchronous SharedPreferences reads of large files, no database queries before the first frame.
- Keep the first screen light. Show cached content or a skeleton quickly, then load the rest.
- Use the SplashScreen API properly, and don't extend the splash artificially to hide slow initialisation.
class App : Application() {
@Inject lateinit var analytics: dagger.Lazy<Analytics> // created on first use, not at startup
override fun onCreate() {
super.onCreate()
// Defer non-critical work until after the first frame is drawn.
Handler(Looper.getMainLooper()).post {
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
analytics.get().initialize()
}
}
}
}2. Baseline Profiles
Android Runtime (ART) uses just-in-time compilation and profile-guided ahead-of-time compilation. A Baseline Profile ships a list of the classes and methods your critical user journeys need, so they're compiled ahead of time from the first launch instead of being interpreted. Android's documentation reports meaningful improvements in startup and rendering. Measure the gain in your own app with the benchmark above (None vs Partial).
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.app") {
// Critical user journey: launch, then scroll the main feed.
pressHome()
startActivityAndWait()
device.findObject(By.res("feed_list"))?.fling(Direction.DOWN)
device.waitForIdle()
}
}Android Studio includes a Baseline Profile Generator module template that sets up the module and the Baseline Profile Gradle plugin for you. Generate the profile on a device or emulator, then commit the generated file.
For UI Automator to find composables with By.res(...), set Modifier.semantics { testTagsAsResourceId = true } near the root and add Modifier.testTag("feed_list") to the list.
3. Rendering and jank
To render smoothly at 60 Hz, each frame must be produced in about 16 ms, and less on 90 or 120 Hz displays. Dropped frames show up as jank during scrolling and animation.
Compose-specific fixes
// 1. Stable keys and content types in lazy lists
LazyColumn {
items(items = posts, key = { it.id }, contentType = { it.type }) { post -> PostRow(post) }
}
// 2. Defer reads of fast-changing state to the layout/draw phase with lambda modifiers
Box(Modifier.offset { IntOffset(0, scrollState.value / 2) }) // โ
no recomposition per pixel
// Box(Modifier.offset(y = (scrollState.value / 2).dp)) // โ recomposes every scroll frame
// 3. derivedStateOf when a calculation changes less often than its inputs
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }
// 4. Remember expensive calculations
val sorted = remember(posts) { posts.sortedByDescending { it.score } }- Prefer immutable, stable parameters (data classes with
vals, immutable collections) so Compose can skip recomposing unchanged items. Use the Compose compiler reports to find unstable classes. - Use Layout Inspector's recomposition counts to find composables that recompose more than expected.
- Load images at display size with an image library such as Coil or Glide, never full-resolution bitmaps into thumbnails.
View-system fixes
- Flatten deep layouts (for example with ConstraintLayout) and avoid nested weights.
- Use
DiffUtil/ListAdapterand avoidnotifyDataSetChanged(). - Never bind expensive work in
onBindViewHolder.
4. Responsiveness and ANRs
An ANR happens when the main thread is blocked for too long, for example for about five seconds while handling input. ANR rate is one of Google Play's core vitals, and exceeding Play's bad-behaviour thresholds can affect your app's visibility. The usual causes are I/O, locks or heavy computation on the main thread.
class App : Application() {
override fun onCreate() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
super.onCreate()
}
}The fix is almost always to move the work off the main thread with coroutines (withContext(Dispatchers.IO) or Dispatchers.Default), and to avoid runBlocking in app code. See Kotlin Coroutines and Flow Explained.
5. Memory
- Find leaks with LeakCanary in debug builds. Common culprits are Activities captured by long-lived objects, unregistered listeners, and Views held by Fragments after
onDestroyView. - Never hold a
Context, View or Activity in a ViewModel or singleton. Use the application context when you truly need one. - Bitmaps are the biggest memory consumer in most apps. Downsample to display size, and let image libraries manage caching.
- Respond to
onTrimMemoryby clearing caches when the system is under pressure.
6. Battery and network
- Use WorkManager with constraints (unmetered network, charging) for deferrable background work, instead of custom wake locks and alarms.
- Batch and cache network requests. Every radio wake-up costs battery. Use HTTP caching and avoid polling. Prefer push (FCM) for updates.
- Stop location and sensor updates when the screen isn't visible, using lifecycle-aware collection.
7. App size
android {
buildTypes {
release {
isMinifyEnabled = true // R8: shrink, optimise, obfuscate code
isShrinkResources = true // remove unused resources
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}- Publish an Android App Bundle so Google Play delivers only the code and resources each device needs.
- Inspect the output with the APK Analyzer. Large native libraries, unused fonts and uncompressed images are common surprises.
- Prefer vector drawables and WebP or AVIF images where appropriate. Remove unused dependencies.
Real-world scenarios
Slow cold start after adding SDKs
- Diagnosis
- A Perfetto trace shows several SDK initialisers in
Application.onCreate(). - Fix
- Lazy and deferred initialisation, plus a Baseline Profile for the launch journey.
- Verify
- The StartupBenchmark median improves and stays stable in CI.
Janky feed in Compose
- Diagnosis
- Recomposition counts show every row recomposing on scroll; an unstable
Listparameter. - Fix
- Stable keys, an immutable UI model, deferred state reads, and properly sized images.
- Verify
FrameTimingMetricshows fewer slow frames.
ANR spike after a release
- Diagnosis
- Vitals ANR clusters point to a synchronous database read in a click handler.
- Fix
- Move the read to a suspend function and show a loading state.
- Verify
- StrictMode stays clean; the ANR rate falls in the next release.
Performance checklist
- โ Startup and scroll Macrobenchmarks exist and run on release-like builds
- โ Baseline Profile generated for critical journeys and committed
- โ No SDK initialisation that isn't needed for the first frame
- โ StrictMode enabled in debug; no main-thread disk or network
- โ LeakCanary in debug builds; no known leaks
- โ Lazy lists use stable keys; images loaded at display size
- โ R8 and resource shrinking enabled; App Bundle published
- โ Android vitals reviewed after every release
Frequently asked questions
What is a good cold start time?
Faster is better, and Android vitals flags startups it considers excessive. Rather than chase a universal number, measure your current median on a mid-range device, set a target, and prevent regressions in CI.
Do Baseline Profiles work for all users?
They're delivered with your app through Google Play and applied by the Android Runtime on supported Android versions. Android's documentation lists the details. Measure the impact with Macrobenchmark using different compilation modes.
Is Jetpack Compose slower than Views?
Not inherently. Both can be fast or slow. Compose performance depends heavily on state handling and stability, and must be judged on release builds, ideally with Baseline Profiles.
Should I add a performance-monitoring SDK?
Android vitals and Crashlytics cover the essentials for many apps. Additional monitoring can help, but every SDK has its own startup and size cost, so measure it.
Conclusion
Android performance work is a loop: observe field data, measure with Macrobenchmark, profile to find the cause, fix, and verify with the same benchmark. The highest-impact fixes are usually the same few: less work at startup, Baseline Profiles, nothing blocking the main thread, stable Compose state, sensibly sized images, and R8 for size.
Next step: add the StartupBenchmark above to your project today and record your baseline number. Every optimisation after that becomes measurable. For the architecture that makes these fixes easier, read Clean Architecture in Android.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Performance work starts with understanding how Android apps are built and how they behave at runtime. Strengthen those fundamentals and the profiling tools start to make sense.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.