Users judge an Android app in the first few seconds. A slow cold start, a stuttering list or a battery-draining background task quietly costs you installs, ratings and retention - and the Play Store surfaces that feedback to everyone. The good news is that most performance problems come from a handful of well-understood causes, and each has a clear fix. This guide walks through the highest-impact techniques, from threading and layouts to Baseline Profiles and profiling.
Keep work off the main (UI) thread
Android renders the UI on the main thread, and it has roughly 16 milliseconds to prepare each frame on a 60Hz display. Any heavy computation, disk read or network call on that thread blocks rendering and causes dropped frames - the “jank” users feel as stutter. The single most important rule of Android performance is: do work off the main thread.
With Kotlin, Coroutines and Dispatchers make this clean. Run I/O and CPU-heavy work on a background dispatcher and only touch the UI on the main dispatcher:
class ReportViewModel(private val repo: ReportRepository) : ViewModel() {
val state = MutableStateFlow<UiState>(UiState.Loading)
fun load() {
// viewModelScope is cancelled automatically when the ViewModel clears
viewModelScope.launch {
// Heavy parsing/I-O runs off the UI thread on the IO dispatcher
val report = withContext(Dispatchers.IO) {
repo.fetchReport() // network + disk work
}
// Back on the main thread by default - safe to update UI state
state.value = UiState.Ready(report)
}
}
}
Using viewModelScope also ties the coroutine to the screen’s lifecycle, so it’s cancelled automatically when the ViewModel is cleared - avoiding wasted work and leaks.
Build efficient layouts
Deeply nested view hierarchies are expensive to measure and lay out. In the XML view system, prefer a flat ConstraintLayout over stacks of nested LinearLayouts, and watch for overdraw - the same pixel being painted several times because of layered backgrounds. In Jetpack Compose, keep composables small and stable, hoist state sensibly, and avoid unnecessary recomposition by not reading changing state higher in the tree than you need to.
Make lists fast and smooth
Long, scrolling lists are where jank shows up most. Use RecyclerView in the view system or LazyColumn in Compose so only visible items are created and recycled. Give items stable keys (and correct DiffUtil/item keys) so the framework can reuse and animate rows efficiently instead of rebuilding them. Keep each row’s layout simple, and never do blocking work - like decoding a bitmap synchronously - inside a bind or item composable.
Avoid memory leaks
Leaks cause growing memory use, more frequent garbage collection (which itself causes jank), and eventually crashes. The usual culprit is holding a reference to something lifecycle-bound after it should be gone - most often a leaked Activity Context. Practical rules that prevent most leaks:
- Never store an Activity or View
Contextin a static field, singleton or long-lived object; use the application context when a long-lived context is genuinely needed. - Respect lifecycle: cancel coroutines, unregister listeners and remove callbacks when a screen is destroyed.
- Use lifecycle-aware components (
viewModelScope,repeatOnLifecycle,LiveData/StateFlow) so observers clean up automatically. - Detect leaks early with a tool like LeakCanary during development.
Load images efficiently
Images are often an app’s biggest memory and bandwidth cost. Use a dedicated loading library - Coil (Kotlin-first, Compose-friendly) or Glide - rather than decoding bitmaps by hand. These libraries handle background decoding, memory and disk caching, and downsampling images to the size actually displayed, which avoids loading a huge photo into a small thumbnail and blowing up memory.
Reduce startup time with Baseline Profiles
Cold start is a user’s first impression. Trim it by deferring non-essential initialization (don’t do everything in Application.onCreate()), using the App Startup library to order dependencies, and keeping the first screen lightweight. Then add a Baseline Profile: it tells the Android runtime which code paths to compile ahead of time, so your critical startup and early-interaction code is already optimised on first run - a measurable win for both startup and early jank.
Shrink app size, minimise battery drain
Enable R8 (with ProGuard rules) for release builds to shrink, obfuscate and optimise your code and strip unused resources - a smaller app installs faster and uses less memory. For battery, be disciplined about background work and networking: batch network requests, avoid frequent wake-ups, and schedule deferrable background tasks with WorkManager, which respects Doze mode and system battery constraints instead of fighting them.
Measure, don’t guess
Optimising by intuition wastes effort on the wrong things. Use the Android Studio Profiler to watch CPU, memory and energy usage while the app runs, and the Macrobenchmark library to measure startup time and scrolling performance in a repeatable, automatable way. Always measure before and after a change so you know it actually helped - and by how much.
If you’d rather have specialists handle this end to end, our Android app development service builds performance in from the start. And if you’re still deciding on your stack, our guide on native vs cross-platform Android is a useful companion read.
Common mistakes to avoid
- Blocking the main thread. Any I/O or heavy work on the UI thread is the number-one cause of jank.
- Premature optimisation. Micro-optimising code that isn’t a bottleneck adds complexity for no benefit - profile first.
- Leaking Context. Static references to Activities or Views are a classic, avoidable memory leak.
- Skipping release builds when testing performance. Debug builds are slower and unoptimised; always measure on a release build with R8 enabled.



