Compose stability: the recomposition model senior devs get wrong
Meta description: How Compose compiler stability, strong skipping mode, and non-restartable functions actually work at the IR level, with metrics and profiling examples.
Tags: kotlin, jetpackcompose, android, architecture, mobile
TL;DR
Strong skipping mode has been the default since Compose Compiler 2.0, and it changes when you actually need @Stable and @Immutable. Most stability “fixes” I see in codebases today are cargo-culted from the pre-2.0 model. One correct fix at the stability layer will do more for your frame times than dozens of remember calls scattered across your composable tree.

The old mental model is dead
Before Compose Compiler 2.0, the skipping rules were strict. If a composable function received any parameter the compiler deemed unstable, the entire function became non-skippable. This led to the widespread pattern of annotating everything with @Stable or @Immutable defensively.
Here is what most teams still haven’t caught up with: strong skipping mode changed the equation.
Weak vs. strong skipping
| Behavior | Weak skipping (pre-2.0) | Strong skipping (2.0+ default) |
|---|---|---|
| Unstable params | Function never skips | Compared via equals() at runtime |
| Stable params | Compared via equals() | Compared via equals() |
| Lambdas | Must be remembered to skip | Automatically wrapped with remember |
@Immutable/@Stable needed? | Often critical | Rarely necessary |
In strong skipping mode, the compiler generates equality checks for all parameters, regardless of stability classification. An unstable List<String> parameter no longer poisons the entire composable. The runtime just calls equals() on it.
What the compiler actually does
The Compose compiler assigns every type a stability status during IR transformation. You can inspect this by generating compiler metrics:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_metrics")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
The output classifies each class:
// composables.txt
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UserCard(
stable name: String
unstable metadata: Map<String, Any>
stable onClick: Function0<Unit>
)
In the pre-2.0 world, that unstable metadata parameter meant UserCard would never skip. With strong skipping, it skips fine. Map implements equals(), so the runtime comparison works.
When @Stable still matters
The annotation isn’t useless. It shifts comparison from runtime equals() to a static guarantee the compiler can trust. This matters when:
- Your type has an expensive
equals(). The compiler trusts the stability contract and may optimize comparison. - Your type is genuinely immutable but the compiler can’t infer it. Types from external modules are unstable by default.
- You need referential equality semantics.
@Stablewith===can avoid deep comparisons.
// External module type — compiler cannot verify stability
@Stable
data class UserProfile(
val id: String,
val displayName: String,
val avatarUrl: String
)
Non-restartable and non-skippable: the IR-level controls
These modifiers operate at a different layer than stability annotations.
@NonRestartableComposable removes the restart scope from a function. Without a restart scope, the function can’t be independently recomposed; it only recomposses when its parent does. Use this for lightweight wrapper composables where the restart scope overhead exceeds the cost of recomposition itself.
@NonSkippableComposable forces the function to always recompose when called, bypassing parameter comparison entirely. This is the right tool for composables that must reflect current state every frame, like animation hosts.
@NonRestartableComposable
@Composable
fun Label(text: String) {
// No restart scope generated — reduces slot table overhead
Text(text = text, style = MaterialTheme.typography.bodyMedium)
}
I’ve seen real impact from removing restart scopes on trivial composables in deep trees. A composable tree 8 levels deep with 40+ restart scopes generates real slot table overhead. Profiling before and after shows a measurable reduction in recomposition time when leaf-level wrappers drop their restart scopes.
Reading metrics: real bottlenecks vs. phantom ones
The numbers here are pretty clear. In production Compose codebases I’ve profiled, the same pattern keeps showing up:
| Issue type | Frequency | Actual performance impact |
|---|---|---|
| Unstable params (with strong skipping) | High in metrics reports | Low, equals() handles it |
Missing remember on derived state | Medium | High, recomputes every frame |
| Excessive restart scopes on leaf nodes | Common | Moderate, adds up in deep trees |
| Lambda allocations (with strong skipping) | Flagged often | Negligible, auto-remembered |
This matches something Paul Graham writes about in “Superlinear Returns”: the biggest gains come from compounding understanding, not linear effort. One engineer who reads the compiler metrics correctly and fixes the actual bottleneck (a derivedStateOf missing in a scroll handler) will outperform a team spending a week annotating every data class with @Stable. I’ve watched this play out more than once.
What to do with all this
Audit your @Stable/@Immutable annotations. If you’re on Compose Compiler 2.0+, most of them are dead weight. Remove the ones protecting types that already implement correct equals(). Less annotation surface means less maintenance burden.
Apply @NonRestartableComposable to trivial leaf composables. Wrapper functions that only delegate to a single child composable rarely benefit from their own restart scope. Profile with Layout Inspector to confirm.
Read compiler metrics, but profile before optimizing. The metrics report will flag dozens of “unstable” types. Under strong skipping mode, most are harmless. Use recomposition counts in Layout Inspector and systrace to find actual over-recomposition before writing a single annotation. The metrics tell you what the compiler thinks. The profiler tells you what matters.