Compose Recomposition: Strong Skipping, Compiler Reports, and State Reads

(Updated on )

Start with the compiler version, not the unstable label

Updated September 30, 2026. An unstable parameter does not, by itself, mean that a Composable cannot be skipped. This guide separates compiler eligibility, runtime state invalidation, and measured frame cost. The snippets are diagnostic patterns, not results from a device benchmark.

Compiler configurationWhat to expect
Kotlin 2.0.20+ with the default Compose compiler configurationStrong Skipping is enabled
An earlier compiler, or a project overriding that featureCheck the actual compiler configuration and generated report
Strong Skipping disabledUnstable parameters can prevent a restartable function from being skippable

With Strong Skipping, restartable Composables are normally skippable even with unstable parameters. Stable arguments use equality comparisons; unstable arguments use instance identity (===). @NonSkippableComposable explicitly opts out. Lambdas created inside Composables are also memoized according to their captures. See the Strong Skipping rules.

This gives a useful first question: is this the same object, a new equal object, or an observed state change? For example, creating a new ordinary List on every parent recomposition can defeat identity-based skipping. Mutating the same plain list in place is not a fix: Compose may not observe that mutation, leaving stale UI. Keep changes observable and model correctness first.

Skipping is also conditional on runtime invalidation. A skippable function that reads changing state can still need to run. A compiler report describes capabilities; it cannot tell you which frames missed a deadline.

Generate and read a release compiler report

For Kotlin 2.x, apply org.jetbrains.kotlin.plugin.compose in each Compose module, using the same version as Kotlin. Configure reports in the top-level composeCompiler block of that module’s build.gradle.kts:

// Module build.gradle.kts; the Compose compiler plugin is already applied.
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("reports/compose")
    metricsDestination = layout.buildDirectory.dir("reports/compose-metrics")
}

Build your real release variant, for example ./gradlew :app:assembleRelease. A project with product flavors has a different task name. Inspect the generated files below that module’s build/reports/compose/ directory. Do not paste old compiler flags into android.composeOptions.freeCompilerArgs; that is not this DSL. The official plugin setup and compiler options cover migration and feature overrides.

Two files answer different questions:

  • *-composables.txt: function eligibility and parameter stability
  • *-classes.txt: inferred class stability, including fields and runtime-stability expressions

An illustrative function report under Strong Skipping can contain both labels:

restartable skippable fun ArticleList(
  unstable articles: List<ArticleUi>
  stable onOpen: Function1<Long, Unit>
)

Read skippable from the function header. The unstable articles line does not cancel it. Do not insert a class report’s <runtime stability> line into this function signature or treat every unstable parameter as a failed performance test. Use the official report guide for the distinction.

The browser-only Compose report reader can organize a pasted composables report. It reads report labels, does not run the compiler, and does not diagnose jank. Pasted text stays in the browser.

Keep stability annotations honest

An ordinary var, a collection interface with mutable implementations, or a model from a module not analyzed by the Compose compiler may be inferred unstable. That is a reason to investigate a measured hotspot, not to annotate the entire data layer.

Prefer a small UI model over passing a network response and marking its wrapper @Stable:

data class ArticleUi(
    val id: Long,
    val title: String,
    val isRead: Boolean
)

All three properties are immutable values with known types. When data changes, create the appropriate updated model through your observable state holder. If a collection is the measured problem, consider immutable collections and inspect the generated result. Neither a new wrapper nor a stability-configuration entry makes mutable internals safe.

@Stable and @Immutable are promises to the compiler. Breaking those promises can produce missed updates. The stability-fix guidance also explains why making every function skippable is not a worthwhile goal.

Use derivedStateOf for a meaningful change boundary

Use derivedStateOf when the UI needs a result that changes less often than the state it depends on. For a back-to-top button, the first visible item can move through many indices while the answer to “am I past item zero?” stays true.

@Composable
fun ScrollToTopControl(listState: LazyListState, onClick: () -> Unit) {
    val visible by remember(listState) {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }
    if (visible) {
        FloatingActionButton(onClick = onClick) { Text("Top") }
    }
}

This example assumes the usual Compose runtime, foundation, and Material imports. firstVisibleItemIndex changes when the first item changes, not for every pixel scrolled. derivedStateOf reduces invalidations caused by that dependency; it does not prevent recomposition for unrelated inputs.

There is no benefit in wrapping ordinary string concatenation or a value that must update at exactly the same rate as its input. Keep the simpler expression unless there is a meaningful boundary. See Compose performance practices.

Defer animation state reads without changing the UI

If the entire card should fade, preserve the entire card as the target. Adding a separate transparent overlay does not produce the same visual behavior.

@Composable
fun ArticleCard(article: ArticleUi) {
    val animatedAlpha = animateFloatAsState(
        targetValue = if (article.isRead) 0.5f else 1f,
        label = "articleAlpha"
    )
    Column(
        modifier = Modifier.graphicsLayer {
            alpha = animatedAlpha.value
        }
    ) {
        Text(article.title)
    }
}

The important detail is that .value is read inside the layer lambda. Reading it first in composition and capturing a plain Float would retain that composition dependency. Other changes to article can still recompose the card. Check clipping, overlap, and alpha compositing when replacing modifiers; see the graphics-layer documentation.

A repeatable investigation and validation loop

  1. Write down Kotlin/compiler and Compose library versions, build variant, device, refresh rate, and the exact interaction that stutters
  2. Use Layout Inspector to locate recomposition and skip counts. A high count is a clue, not a frame-time result; its debugging guide describes the controls
  3. Check the relevant release compiler report. Then inspect object creation and where state is actually read in that scope
  4. Change one thing while preserving behavior. Repeat the same interaction, including list insertion/reordering and updated click callbacks
  5. Measure a release-like build on a physical device with a repeatable Macrobenchmark scenario or inspect a Perfetto trace. Keep benchmark conditions and raw results with the change

Do not fail CI simply because a new unstable label appears. Review changes in known hot paths, then use measurements and correctness tests to decide whether they are regressions. Avoid publishing a percentage improvement without the actual before/after runs.

Quick answers

Why does a skippable function still recompose? Its observed state may have invalidated it, its inputs may differ, or it may be entering composition. Inspect the particular call and interaction.

Should every lambda get a manual remember? No. Check Strong Skipping first. When manual caching is justified, include all changing captures in the keys so a callback does not retain stale data.

Does fewer recompositions mean a faster screen? Not necessarily. Layout, drawing, image decoding, I/O, and the cost of each recomposition also matter.

Further reading