Compose Performance & Stability
Irbisa · cheatsheetSeptember 13, 2026

Compose Performance & Stability

Stability, skipping, deferred reads, phases, Lazy keys, Modifier.Node, compiler metrics

Senior Developer16 itemscompressed for a skim
  1. 01

    A screen drops frames while a value animates, but the Layout Inspector shows almost no recompositions — which phase is burning the time?

    Medium

    Compose runs composition, layout and drawing as three separate phases, and zero recompositions means the cost has moved into the other two — so attribute the frame to a phase before you change any code.

    // Cheap phase attribution with no profiler: each phase logs separately.
    fun Modifier.phaseProbe(tag: String) = this
      .layout { measurable, constraints ->
        Log.d("phase", "$tag measure")
        val placeable = measurable.measure(constraints)
        layout(placeable.width, placeable.height) {
    …
  2. 02

    The compiler report marks your ArticleCard unstable because it takes a List<Tag> — what does Compose's stability inference actually check?

    Medium

    Stability is a promise about comparison: a type is stable when equals is a trustworthy answer to "did this change", and when any change to a public property is published to the composition through snapshot state.

    // From <module>-classes.txt in the Compose compiler report:
    //
    //   unstable class Article {
    //     stable val id: String
    //     unstable val tags: List<Tag>      <- this field poisons the whole class
    //   }
    …
  3. 03

    Strong skipping has been the default since the Compose compiler shipped with Kotlin 2.0.20 — what did it change, and what does it still not fix?

    Medium

    Strong skipping makes every restartable composable skippable by comparing unstable parameters with instance identity instead of refusing to skip at all, and it memoizes lambdas for you.

    data class Article(val id: String, val tags: List<Tag>)   // unstable param
    
    @Composable
    fun Feed(articles: List<Article>) {
      LazyColumn {
        items(articles, key = { it.id }) { article ->
    …
  4. 04

    A header slides with the scroll via Modifier.offset(y = scroll.value.dp) and the whole screen recomposes every frame — what does Modifier.offset { } change?

    Medium

    The lambda overload moves the state read out of composition and into the layout phase, so a value changing every frame re-runs placement instead of re-running your composable functions.

    @Composable
    fun CollapsingHeader(listState: LazyListState) {
      // ❌ Composition read: the body of CollapsingHeader — and everything it
      //    emits — re-runs on every scroll frame.
      Box(
        Modifier
    …
  5. 05

    A reviewer says your remember { derivedStateOf { items.filter(query::matches) } } should just be remember(items, query) — who is right?

    Medium

    The reviewer is right: derivedStateOf only tracks snapshot state reads, so over ordinary parameters it captures the first values in its closure and never recomputes.

    // ❌ Stale forever: `items` and `query` are parameters, not snapshot state,
    //    so the closure keeps whatever was passed on the first composition.
    @Composable
    fun BadResults(items: List<Item>, query: String) {
      val filtered by remember { derivedStateOf { items.filter { it.matches(query) } } }
      ResultList(filtered)
    …
  6. 06

    A feed with three different row layouts still drops frames on fast scroll even though every item has a stable key — what else does a LazyColumn need?

    Medium

    key controls identity; contentType controls reuse — without it every row is a candidate for every recycled slot, so a slot that held a text row gets handed to a photo row and the runtime throws the whole subtree away instead of updating it.

    sealed interface Row {
      data class Header(val id: String, val title: String) : Row
      data class Photo(val id: String, val url: String) : Row
      data class Comment(val id: String, val text: String) : Row
    }
    …
  7. 07

    Your feed screen recomposes far more than it should — how do you get the Compose compiler itself to tell you which parameter is to blame?

    Medium

    Switch on the compiler's reports and metrics in the composeCompiler block, build the variant you ship, and read <module>-composables.txt, which prints every composable with a restartable/skippable header and each parameter marked stable or unstable.

    // build.gradle.kts
    composeCompiler {
      // ./gradlew assembleRelease -PcomposeReports
      if (project.findProperty("composeReports") != null) {
        reportsDestination = layout.buildDirectory.dir("compose_reports")
        metricsDestination = layout.buildDirectory.dir("compose_metrics")
    …
  8. 08

    The Layout Inspector shows 300 recompositions on a header that never changes — how do you read that number and trace it back to a state read?

    Medium

    A recomposition count is how many times that restart scope actually re-ran, and 300 on a static header means something invalidated it 300 times — the Skips column beside it tells you whether the runtime managed to bail out or not.

    // A count you can read without the inspector, in any build:
    // the array survives recomposition, SideEffect runs once per composition
    // that was actually applied.
    @Composable
    fun LogRecompositions(tag: String) {
      val count = remember { IntArray(1) }
    …
  9. 09

    A child stops skipping the moment its parent passes a lambda that reads an animating progress value — why, and how do you fix it without the callback going stale?

    Hard

    Strong skipping does memoize the lambda, but the memo is keyed on what the lambda captures — capture a value that changes every frame and you allocate a new lambda every frame, so the child's parameter comparison fails.

    // A new lambda every frame: the compiler's memo is keyed on `progress`
    @Composable
    fun Player(progress: Float, onSeek: (Float) -> Unit) {
      Controls(onSkip = { onSeek(progress + 10f) })   // Controls never skips
    }
    …
  10. 10

    Rotating the phone swaps the layout from a Column to a Row and the video player restarts from zero — how do you move a subtree between parents without losing its state?

    Hard

    State in Compose is identified by position in the composition, so calling the same composable from a different call site creates a different group: the old one is disposed and a new one built from scratch — movableContentOf is the API that relocates a group instead.

    // Two call sites: rotating disposes the player and builds a new one
    @Composable
    fun ScreenBroken(wide: Boolean, url: String) {
      if (wide) Row { Player(url); Details() }
      else Column { Player(url); Details() }
    }
    …
  11. 11

    Your custom Modifier.pulsingBorder() is written with composed { } and shows up in every recomposition profile — what does it cost, and what changes if you rewrite it as a Modifier.Node?

    Hard

    A composed { } modifier is a composable that runs once per usage site on every composition, and because two composed instances never compare equal it also defeats the equality check the modifier chain would otherwise use to skip work.

    // composed: a composition group per usage, no equals, invalidates composition
    fun Modifier.pulsingBorderSlow(color: Color) = composed {
      val alpha by rememberInfiniteTransition(label = "pulse")
        .animateFloat(0f, 1f, infiniteRepeatable(tween(800)), label = "alpha")
      drawBehind { drawRect(color.copy(alpha = alpha), style = Stroke(2f)) }
    }
    …
  12. 12

    Someone put a BoxWithConstraints inside every LazyColumn item and the scroll got worse — what does subcomposition actually cost, and what should they have used?

    Hard

    BoxWithConstraints is a SubcomposeLayout: it moves the composition of its children into the measure pass, so each row now composes during layout, every time it is measured, instead of once during composition.

    // A SubcomposeLayout per row: composition moves into the measure pass
    LazyColumn {
      items(posts, key = { it.id }) { post ->
        BoxWithConstraints {
          if (maxWidth > 600.dp) WideRow(post) else NarrowRow(post)
        }
    …
  13. 13

    The compiler report says your feed row is unskippable because of a model class from a third-party SDK — what are your options, and what does each one cost you?

    Hard

    Three real options — declare the class stable in a stability configuration file, map it into a type you own, or apply the Compose compiler to the module that produces it — and only the middle one is a promise you can actually keep.

    // build.gradle.kts
    composeCompiler {
      stabilityConfigurationFiles.add(
        rootProject.layout.projectDirectory.file("compose_stability.conf")
      )
      reportsDestination = layout.buildDirectory.dir("compose_reports")
    …
  14. 14

    A freshly installed build janks through the first scroll of a Compose list and is smooth on every scroll after — what is the runtime doing, and what actually fixes it?

    Hard

    Everything on that first pass is cold: ART is interpreting code that has never been compiled, classes are still being loaded, the slot table and layout tree are being built for the first time, and the glyph and shader caches are empty.

    // Generate the profile for THIS journey, not just for cold start
    @RunWith(AndroidJUnit4::class)
    class FeedBaselineProfile {
      @get:Rule val rule = BaselineProfileRule()
    
      @Test fun feedScroll() = rule.collect(packageName = "com.example.app") {
    …
  15. 15

    How do you stop a Compose performance regression from merging, and what makes a scroll number stable enough to fail a pull request on?

    Hard

    Use two gates with different characters: a compiler-metrics diff that is fully deterministic and runs in seconds on every PR, and a Macrobenchmark scroll on one fixed device that only means anything once you control the noise.

    // build.gradle.kts - deterministic, runs on every PR
    composeCompiler {
      metricsDestination = layout.buildDirectory.dir("compose_metrics")
      reportsDestination = layout.buildDirectory.dir("compose_reports")
    }
    …
  16. 16

    Recomposition counts look clean but frames are still late and the time is sitting on RenderThread — where does a Compose UI usually burn GPU time?

    Hard

    Look for offscreen buffers and fill rate: an alpha or a blur that forces the renderer to allocate a layer, shadows and non-rectangular clips, and stacked opaque backgrounds painting the same pixels several times over.

    // An offscreen buffer for a fade that did not need one
    Box(Modifier.alpha(fade)) { Row { Icon(icon, null); Text(label) } }
    
    // Children do not overlap -> modulate alpha per draw op, no offscreen buffer
    Box(
      Modifier.graphicsLayer {
    …