Halfway: The 10 Android Performance Rules I'd Keep
Fifty posts in, the ten performance rules that survive contact with real apps: measure on real hardware at p90, know your startup phase, ship a Baseline Profile, fix stability rather than composable bodies, and never claim a win without a before and an after.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Ten rules, in the order I'd teach them: measure on the hardware users actually have; use p90, never the mean; identify the startup phase before optimising it; ship a Baseline Profile; treat recomposition as a stability problem, not a slow-code problem; defer the read, not the work; use keys in every lazy list; distrust "no leak detected"; test the R8 build because shrinking is a correctness surface; and never claim a win without a before number and an after number.
1. Measure on the device your users have
Your development phone is roughly the 95th percentile of your install base. Everything is fast on it, including the code that's about to cause a one-star review.
Keep a deliberately slow device — a three-year-old mid-range phone, storage 80% full, forty apps installed, battery saver available — and make it the device that decides. If you can't, cap what you trust from the fast one: it tells you whether a change made things better, never whether things are good.
Pair that with field data. Play Vitals and Firebase Performance measure the distribution you actually ship into. Local profiling explains a problem; field data tells you whether it's a problem.
2. p90, never the mean
An app with a mean frame time of 11ms can be visibly awful. The mean is dominated by the thousands of frames that were fine; the user remembers the one that took 400ms during a scroll.
Track p90 and p99 for frame time, startup, and any user-visible operation. And set the target as a percentile too — "p90 cold start under 1.5s" is a goal you can falsify. "Fast startup" is not.
Related, and worth stating separately: a single 400ms frame is worse than forty 30ms frames. Optimise for the tail, not the total.
3. Know which startup phase you're in
Cold start has five distinct phases — process fork and class loading, Application.onCreate, Activity
creation, first frame drawn, and first useful frame — and they have nothing in common except that they
all end up as one number in the dashboard.
Library initialisers in Application.onCreate, an over-inflated first layout, and a first frame that
renders empty placeholders for 900ms are three different bugs with three different fixes. Measuring the
total tells you which app is slow. Measuring the phase tells you what to do on Monday.
Details in App startup: the five phases.
4. Ship a Baseline Profile
Still the highest win-to-effort ratio available on Android. A profile file and a Macrobenchmark run routinely buys 20–30% off cold start and a visible improvement in first-scroll jank, because it tells ART which code paths to AOT-compile instead of interpreting them the first time a user touches them.
It costs an afternoon, it requires no architectural change, and it keeps paying as the app grows. Regenerate it when the critical path changes — a stale profile silently decays.
See Baseline Profiles.
5. Recomposition is not slow code
The instinct — "this composable is called too often, make it cheaper" — is almost always wrong. The composable body is rarely the cost. The cost is that Compose can't tell the parameter didn't change, so it recomposes a subtree that had no reason to.
Fix the input, not the body: unstable types, lambdas allocated fresh each pass, a List where the
compiler wanted an immutable collection. The compiler's stability rules are checkable, not folklore —
turn on the metrics report and read what it says about your classes.
Recomposition and stability ·
@Immutable vs @Stable
6. Defer the read, not the work
The single most useful Compose performance technique, and the least intuitive:
// Reads offset here → this whole composable recomposes on every scroll pixel
Header(offset = scrollState.value)
// Reads offset inside the layout/draw phase → recomposition skipped entirely
Header(offset = { scrollState.value })
Where state is read determines which scopes invalidate. Moving the read down — into a lambda, into
graphicsLayer, into drawBehind — can turn a per-frame recomposition into a per-frame draw, which is
an order of magnitude cheaper.
Deferred reads ·
derivedStateOf
7. Keys first, in every lazy list
LazyColumn without stable keys re-uses slots by position, so inserting at the top invalidates
everything below it, loses scroll position, and resets item state. It's the most common Compose list
bug and the cheapest to fix:
LazyColumn {
items(items, key = { it.id }, contentType = { it.type }) { item -> Row(item) }
}
contentType is the second lever: it lets Compose re-use the right slot for heterogeneous lists rather
than rebuilding. Do both before you try anything clever.
Lazy list keys and contentType
8. "No leak detected" means "no watched object leaked"
LeakCanary watches Activities, Fragments, Views and ViewModels. Objects held by remember were never
handed to it, so a self-made CoroutineScope, an undisposed listener, or a collection that only ever
grows will pass in silence.
The measurement that actually answers the question: navigate in and out ten times, force GC, capture a heap dump, filter to your package, sort by retained size. Ten instances where there should be one is unambiguous, and it takes four minutes.
What LeakCanary won't tell you
9. R8 is a correctness surface
Shrinking is not only a size lever — it removes and rewrites code, which means it can break reflection, serialisation, DI and anything reached by name. A team that only exercises debug builds has not tested the artefact it ships.
Run your instrumented tests against a minified build in CI. Keep -printusage output so you can see
what disappeared. When something is mysteriously retained, -whyareyoukeeping prints the chain.
10. A before number and an after number
Every performance change needs both, from the same device, same conditions, enough runs to see past the noise. No exceptions, including the "obviously faster" ones — those are precisely where intuition fails, because obviously-faster changes are the ones nobody bothers to measure.
Without both numbers you have a story. Stories accumulate into a codebase full of defensive complexity that nobody can remove, because nobody can prove it isn't helping.
The tooling for this is good now: Macrobenchmark for startup and scroll, Perfetto for "where did the time go", and the Compose metrics report for stability. Reading a Perfetto trace
The one I got wrong
For years I optimised what was interesting rather than what was slow. Rewriting a parser to be allocation-free is satisfying. It also saved 4ms on a screen whose real problem was a 600ms synchronous disk read on the main thread, three files away, that I'd never looked at because it was boring.
A profiler is boring and correct. Intuition is exciting and, on Android, wrong often enough that acting on it without a trace is how afternoons disappear.
Halfway
Fifty posts in, the pattern across all of them is the same: performance work is a measurement discipline that occasionally requires code changes — not a code discipline that occasionally requires measurement. Get that order right and the ten rules above are mostly consequences.
The next fifty go deeper: frame timing beyond "it feels smooth", the Compose compiler metrics report, configuration cache, and what "good" means for a feature powered by a model you don't control.