---
title: "Compose Stability Contracts and Strong Skipping Mode: The Recomposition Model Senior Devs Get Wrong"
published: true
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, android, architecture, performance
canonical_url: https://blog.mvpfactory.co/compose-stability-strong-skipping-mode-recomposition-model
---
## What You Will Learn
Let me show you a pattern I use in every project — and more importantly, the pattern I've *stopped* using. By the end of this tutorial, you will understand how the Compose compiler assigns stability to types, why strong skipping mode (default since Compose Compiler 2.0) makes most `@Stable`/`@Immutable` annotations dead weight, what `@NonRestartableComposable` and `@NonSkippableComposable` actually do at the IR level, and how to read compiler metrics to find real recomposition bottlenecks. One correct fix at the stability layer will do more for your frame times than dozens of `remember` calls scattered across your composable tree.
## Prerequisites
- Compose Compiler 2.0+ (strong skipping mode is the default)
- Android Studio with Layout Inspector
- Familiarity with Jetpack Compose basics and recomposition concepts
## Step 1: Understand the New Skipping Model
Before Compose Compiler 2.0, the skipping rules were strict. If a composable received any parameter the compiler deemed unstable, the entire function became non-skippable. This led teams to annotate everything with `@Stable` or `@Immutable` defensively.
Here is the gotcha that will save you hours: strong skipping mode changed the equation.
| 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>` no longer poisons the entire composable. The runtime just calls `equals()` on it.
## Step 2: Generate and Read Compiler Metrics
Here is the minimal setup to get this working. Add this to your module-level build script:
kotlin
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_metrics")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
Build your project. The output classifies each class:
kotlin
// composables.txt
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UserCard(
stable name: String
unstable metadata: Map
stable onClick: Function0
)
In the pre-2.0 world, that `unstable metadata` meant `UserCard` would never skip. With strong skipping, it skips fine — `Map` implements `equals()`, so the runtime comparison works.
## Step 3: Know When @Stable Still Matters
The annotation isn't useless. It shifts comparison from runtime `equals()` to a static guarantee. This matters when:
1. Your type has an **expensive `equals()`** — the compiler trusts the stability contract and may optimize comparison.
2. Your type is **from an external module** — external types are unstable by default.
3. You need **referential equality semantics** — `@Stable` with `===` avoids deep comparisons.
kotlin
// External module type — compiler cannot verify stability
@stable
data class UserProfile(
val id: String,
val displayName: String,
val avatarUrl: String
)
## Step 4: Apply IR-Level Controls
The docs do not mention this, but `@NonRestartableComposable` removes the restart scope from a function — it can only recompose when its parent does. Use this for lightweight wrapper composables where restart scope overhead exceeds the cost of recomposition.
`@NonSkippableComposable` forces recomposition every call, bypassing parameter comparison. This is correct for composables that must reflect current state every frame, like animation hosts.
kotlin
@NonRestartableComposable
@Composable
fun Label(text: String) {
// No restart scope generated — reduces slot table overhead
Text(text = text, style = MaterialTheme.typography.bodyMedium)
}
A composable tree 8 levels deep with 40+ restart scopes generates real slot table overhead. Profiling before and after shows measurable reduction in recomposition time when leaf-level wrappers drop their restart scopes.
## Gotchas
| 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 |
**The biggest trap:** treating every "unstable" flag in compiler metrics as a problem. Under strong skipping mode, most are harmless. 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`.
## Conclusion
Audit your `@Stable`/`@Immutable` annotations. If you are on Compose Compiler 2.0+, most are dead weight. Remove the ones protecting types that already implement correct `equals()`. Apply `@NonRestartableComposable` to trivial leaf composables. And read compiler metrics, but **profile before optimizing** — the metrics tell you what the compiler thinks, the profiler tells you what matters.
**Resources:**
- [Compose Compiler Metrics](https://developer.android.com/develop/ui/compose/performance/stability)
- [Strong Skipping Mode](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
- [Layout Inspector](https://developer.android.com/studio/debug/layout-inspector)
Top comments (0)