Jetpack Compose Strong Skipping: What Changed and How to Verify It Works
Strong Skipping mode in Jetpack Compose Compiler 1.5.0+ infers lambda stability automatically, but conservative capture analysis and cross-module limits mean you must verify with compiler metrics and Layout Inspector before relying on it.
19 May 2026, 13:15 UTC

The Problem: Recomposition Without Clear Signals
You ship a Compose screen, watch the Layout Inspector, and see composables recompose on every frame even though the data hasn't changed. Before Compose Compiler 1.5.0, the fix was sprinkling @Stable or @Immutable annotations everywhere and hoping the compiler agreed. Strong Skipping mode, enabled by default since Compose Compiler 1.5.0 (Kotlin 1.9+), changes the contract: the compiler now infers stability from lambda captures and value-class equality without requiring annotations. The takeaway: you can often delete those annotations, but you must verify the compiler's inference matches your runtime reality.
How Strong Skipping Decides to Skip
The compiler analyzes each composable parameter at compile time. For a lambda parameter, it walks the captured variables: primitives, value classes, and objects already inferred stable count as stable; a captured var or a mutableStateOf not wrapped in snapshotFlow marks the lambda unstable. If every parameter is either referentially equal to the previous call or inferred stable, the composable is skipped entirely—no composition, no recomposition, no node visit.
This inference is conservative. A local var count = 0 captured in a lambda makes that lambda unstable even if count never mutates at runtime. Cross-module calls without @Stable on the library side also break inference because the compiler cannot see the callee's capture set.
Worked Example: Wrapping Primitives in a Value Class
Consider a list item that receives an Int id, a String title, and an onClick: () -> Unit lambda. Each primitive is stable, but the lambda captures a var selectedId from the parent scope, so the whole composable recomposes on every click elsewhere.
@Composable
fun ItemRow(id: Int, title: String, onClick: () -> Unit) { ... }
@Composable
fun Parent() {
var selectedId by remember { mutableStateOf(0) }
Column {
items.forEach { item ->
ItemRow(item.id, item.title) { selectedId = item.id }
}
}
}Refactor by grouping the read-only data into an @Immutable value class and hoisting the lambda capture into a stable function reference:
@Immutable
@JvmInline
value class ItemData(val id: Int, val title: String)
@Composable
fun ItemRow(data: ItemData, onClick: (Int) -> Unit) { ... }
@Composable
fun Parent() {
var selectedId by remember { mutableStateOf(0) }
Column {
items.forEach { item ->
ItemRow(ItemData(item.id, item.title), onClick = { selectedId = it })
}
}
}Now ItemData is a single reference; the compiler sees it as stable. The lambda { selectedId = it } captures only the parameter it (an Int), so it is inferred stable. Layout Inspector shows ItemRow recomposition count stuck at 1 while selectedId changes.
Trade-off: Skipping Can Hide Caller-Side Work
A composable that reads a 10,000-element list but skips because the list reference didn't change still forces the caller to create that list on every composition. Strong Skipping moves the cost upstream. Profile the caller with Macrobenchmark, not just the skipped composable. Also, Baseline Profiles collected on older compiler versions miss the new skip paths; regenerate them after upgrading to 1.5.0+ or you'll ship stale inline/skip bytecode hints that degrade startup.
Verify Before You Trust
- Add
compose.compiler.metrics.destination=build/compose_metricstogradle.propertiesand rebuild. Open the JSON report; each parameter showsstability: "stable"or"unstable"with a reason. - In Layout Inspector, enable "Recomposition counts" and interact with the UI. Counts should stay flat for composables you expect to skip.
- Run a Macrobenchmark Baseline Profile generation (
./gradlew :app:generateBaselineProfile) after any stability refactor. Compare startup latency and frame timing before and after.
If the metrics report shows unstable on a parameter you believe is stable, check for captured vars, cross-module lambdas without @Stable, or mutable state not exposed via snapshotFlow or derivedStateOf. Fix the capture, recompile, and re-verify.
Actionable Closing
Upgrade to Compose Compiler 1.5.10+ and Kotlin 1.9.22, enable the metrics destination, and run the three verification steps on one screen this sprint. Delete @Stable annotations that the report now marks redundant. Regenerate your Baseline Profile. You'll ship less bytecode, fewer recompositions, and a measurable startup win—without guessing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.