What makes a Composable 'skippable' and 'stable', and why does it matter for performance?
Not every recomposition actually re-runs your Composable — stability and skippability decide whether Compose reuses the last frame or pays to rebuild it.
LEARN BY DOINGPut your intuition to work3 steps
What makes a Composable 'skippable' and 'stable', and why does it matter for performance?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.Not every state change in a Compose app forces every composable that could theoretically react to actually re-run. Compose's runtime tracks which composables read which pieces of state, and when that state changes it only invalidates the composables that read it — the rest of the tree is left alone. But invalidation is only half the story: even a composable inside the invalidated scope can sometimes avoid running its body entirely if the compiler can prove nothing about its output could have changed. That avoidance is what "skippable" means, and it's the single biggest lever for Compose performance that has nothing to do with your algorithm and everything to do with your types.
#Restartable vs. skippable — two different questions
The Compose compiler asks two separate questions about every @Composable function it processes. Restartable asks: can this function be re-entered on its own as an independent recomposition scope, without re-running its caller? Almost every composable with at least one parameter is restartable. Skippable asks a stricter question: if this function is asked to recompose, can the runtime instead do nothing and reuse what it already emitted last time? A function can be restartable without being skippable — that's the case that quietly costs teams the most performance, because the code looks fine and nothing crashes.
#What makes a type stable
Skipping requires every parameter to be stable: the compiler must be able to prove that if two instances are equal, nothing observable about them will ever diverge afterward, and that if a public property does change, something (like MutableState) will notify Compose about it. Primitives, String, and function types (lambdas) are stable by default. A data class built entirely of val properties of stable types is stable. The moment a var shows up, or a property's type is an interface the compiler can't fully see through, stability breaks — and critically, this includes Kotlin's own List, Set, and Map interfaces, because they could be backed by a mutable ArrayList or HashMap at runtime even though the declared type promises nothing of the sort. A List<Product> parameter is therefore unstable by default, and any composable taking one directly loses skippability.
// Unstable: a `var` property, plus a java-collection-shaped parameter.data class ListItemUiModel(var isSelected: Boolean, // var breaks stabilityval label: String,)@Composablefun ItemRow(items: List<ListItemUiModel>, onClick: (ListItemUiModel) -> Unit) {// `items` is unstable (the List interface), and so is ListItemUiModel (var).// This function is restartable but NOT skippable.Column {items.forEach { item ->Text(item.label, Modifier.clickable { onClick(item) })}}}
import androidx.compose.runtime.Immutableimport kotlinx.collections.immutable.ImmutableList@Immutabledata class ListItemUiModel(val isSelected: Boolean, // val -> stableval label: String,)@Composablefun ItemRow(items: ImmutableList<ListItemUiModel>, onClick: (ListItemUiModel) -> Unit) {// Both the collection and the element type are provably stable now —// Compose skips this call whenever items and onClick are unchanged.Column {items.forEach { item ->Text(item.label, Modifier.clickable { onClick(item) })}}}
@Immutable and @Stable exist to make a promise to the compiler when it can't infer stability on its own — an interface you control, or a class from a library you can't annotate. Lying with either annotation is a real bug class: Compose will skip recomposition based on a stability guarantee that the class then violates, and the UI silently goes stale with no exception to point at.
#How the runtime decides, step by step
#Strong skipping changes the rules
Strong skipping mode (shipped in the Compose compiler from version 1.5.4, and the default with the Kotlin 2.0 Compose plugin) relaxes the stability requirement: a composable can be marked skippable even with unstable parameters, because the runtime falls back to comparing the previous parameter instance by reference (===) instead of demanding a stability proof. It also automatically remembers lambdas defined inline inside a composable, which used to be a manual remember { } chore. Strong skipping narrows the gap between "wrote idiomatic Kotlin" and "wrote Compose-performant Kotlin," but it doesn't make stability irrelevant — a genuinely stable type still skips more often, because reference equality fails on every newly-constructed instance even when its contents are identical.
This matters in practice wherever recomposition happens at scale — a LazyColumn re-laying-out on every keystroke of an unrelated search box, a chat screen re-running every message row because the list parameter is unstable. Skipping is nearly free: a handful of equality checks. Recomposing is not: it re-runs the function body, re-walks composition, and re-diffs the emitted UI tree. The gap between those two costs, multiplied across a scrolling list, is where "why is this screen janky" answers actually live.
🧠 Check your understanding
0/4 · 0/4 answered1. Why is a plain List<Product> parameter treated as unstable by the Compose compiler?
2. What's the difference between 'restartable' and 'skippable'?
3. What does strong skipping mode change?
4. How do you find out which of your composables aren't skippable?