6 min read

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.

Jetpack ComposePerformanceAndroid
HR

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 (-P compiler 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.

Keep reading