Your Compose Performance Bug Isn't Slow Code — It's Recomposition
Most Compose jank isn't heavy work — it's work happening too often. A senior look at recomposition, stability, and how to actually measure it before you optimise anything.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
There's a pattern I see in almost every Compose performance review: an engineer profiles a janky screen, finds a function that "looks expensive," and starts optimising the work inside it. Nine times out of ten, that function isn't slow. It's just running far more often than it should. The bug isn't the code — it's the recomposition.
What recomposition actually is
Compose builds your UI by calling composable functions. When a composable reads a piece of state that later changes, Compose remembers that dependency and re-invokes that function to produce the new UI. That re-invocation is a recomposition, and on its own it's cheap and correct — it's how the framework stays declarative.
The problem starts when Compose can't prove that an input hasn't changed. When that happens, it plays it safe and recomposes anyway. Do that across a big subtree, on every frame of a scroll or animation, and you get dropped frames that no amount of "faster code" will fix.
Stability: the thing that decides how much re-runs
Compose classifies every parameter as stable or unstable. A type is stable if Compose
can trust that equals() reliably reflects whether its observable state changed. Primitives,
String, and types marked @Immutable/@Stable are stable. Many everyday types are not —
and the most common offender is a plain collection:
@Composable
fun EventList(events: List<Event>) { … } // List<T> is treated as UNSTABLE
List<Event> is an interface; Compose can't guarantee the underlying implementation is
immutable, so it marks the parameter unstable. Every parent recomposition now forces
EventList to recompose too — even when the events are identical.
Two fixes, depending on your codebase:
// 1. Make the model itself stable
@Immutable
data class Event(val id: String, val title: String, val startsAt: Instant)
// 2. Use a type Compose knows is immutable
import kotlinx.collections.immutable.ImmutableList
@Composable
fun EventList(events: ImmutableList<Event>) { … }
With kotlinx.collections.immutable (or a @Immutable wrapper), the parameter becomes stable,
and EventList only recomposes when the list genuinely changes.
The other two traps
Unstable lambdas. A lambda that captures changing state is a new object on each
composition, so any composable receiving it recomposes. Hoist stable callbacks or wrap them so
the reference is preserved. Method references (onClick = viewModel::toggle) are your friend.
Reading state too high. If you read a frequently-changing value near the root of a screen, the whole screen is a recomposition dependency. Push the read down to the smallest composable that needs it — or defer it entirely into a lambda:
// Reads scroll offset during composition → recomposes on every pixel
Box(Modifier.offset(y = scrollState.value.dp))
// Defers the read to the layout phase → no recomposition
Box(Modifier.offset { IntOffset(0, scrollState.value) })
Modifier.offset { … } (and graphicsLayer { … }, drawBehind { … }) read state in the
layout/draw phase, skipping recomposition altogether. For anything that changes every frame,
this is the single highest-leverage move.
Measure first — always
None of this should be done by guessing. Compose gives you the numbers:
- Layout Inspector → recomposition counts shows exactly which composables recompose and how often. If a static header shows thousands of recompositions during a scroll, you've found your bug — no profiler required.
- Composition tracing and the Compose compiler metrics report (
-Pcompiler flags that emit per-composable stability info) tell you which of your parameters are unstable and why.
Turn on recomposition counts, reproduce the jank, and read the tree. The offender is usually obvious — and it's almost never the function you assumed was "expensive."
The senior takeaway
Junior performance work asks "how do I make this code faster?" Senior performance work asks "why is this code running at all?" In Compose, that reframing is everything: stabilise your models, hoist and defer your state reads, and let the framework skip the work instead of racing to finish it. You'll ship smoother screens by deleting recompositions, not by micro-optimising the ones you didn't need.