Compose performance: measure before you optimize recomposition

Jetpack Compose recomposition and performance visualization

Jetpack Compose makes state-driven UI easier to build, but its performance vocabulary can lead teams in the wrong direction. A high recomposition count looks suspicious, so engineers start adding remember, stability annotations, and wrapper models before proving that users experience a slow frame.

The better starting point is simple: recomposition is work, not automatically a performance defect. A small composable can recompose cheaply. One expensive layout or image operation can cause visible jank even when its recomposition count is low.

Begin with a release-like measurement

Do not judge Compose performance from a debug build alone. Debug tooling changes runtime behavior and can exaggerate costs. Reproduce the problem in a release build with R8 enabled, then measure a real user journey: cold start, opening a detail screen, scrolling a feed, or changing a filter.

Use system traces, the Layout Inspector, Macrobenchmark, and app-specific Baseline Profiles to answer three questions:

  • Which interaction misses the frame budget?
  • Is the time spent in composition, layout, drawing, image work, or I/O?
  • Is the problem repeatable on the devices your users actually have?

This prevents “optimization” that only makes the code harder to maintain.

Keep state reads close to the work they change

Compose invalidates the scopes that read changed state. If a screen-level composable reads rapidly changing state and passes values down, more of the tree may be reconsidered than necessary. Move the read closer to the element that needs it, or pass a lambda to a modifier when the API supports deferred reads.

@Composable
fun CollapsingHeader(scrollOffset: () -> Int) {
    Box(
        Modifier.offset {
            IntOffset(x = 0, y = -scrollOffset())
        }
    ) {
        HeaderContent()
    }
}

The lambda-based modifier can read the latest value during layout instead of forcing composition to handle every offset change.

Give lazy content identity

For feeds and dashboards, stable item keys help Compose preserve item identity when content moves or updates. Keep the key tied to a durable domain identifier, not the list position.

LazyColumn {
    items(
        items = articles,
        key = { article -> article.id }
    ) { article ->
        ArticleRow(article)
    }
}

Also keep expensive sorting, filtering, and parsing out of the item body. Calculate it in a state holder or cache it at the correct lifecycle boundary.

Use stability tools carefully

Modern Compose compiler behavior, including strong skipping, removes much of the manual work that older performance advice recommended. Strong skipping is enabled by default with Kotlin 2.0.20. Restartable composables with unstable parameters can be skipped when the same instances are provided, and lambdas inside composables are memoized automatically.

That does not make mutability safe. UI models should still expose predictable state. Use immutable values where practical, and never apply @Stable or @Immutable to a type that violates the annotation contract. A false annotation can produce stale UI, which is worse than an extra recomposition.

Optimize stability after a trace identifies composition as meaningful work, not because a counter changed color.

A repeatable workflow

  1. Choose one slow user journey and define the expected outcome.
  2. Measure it in a release-like build.
  3. Locate the expensive phase.
  4. Make the smallest change that targets that phase.
  5. Measure again and keep the change only when the result improves.

This workflow keeps performance engineering connected to user experience and protects the codebase from speculative complexity.

Further reading

About the Author

You may also like these