@Immutable vs @Stable: What You're Actually Promising the Compose Compiler
@Stable and @Immutable are contracts, not hints — the Compose compiler trusts them completely and skips recomposition on that basis. What each one guarantees, how a List field quietly breaks them, and how to verify stability instead of guessing.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — @Stable promises that a type notifies Compose when it changes and that equals() is
consistent. @Immutable promises the type never changes at all. Compose uses both to skip
recomposition, and it does not verify them. Break the promise and you get a silently stale UI —
no crash, no warning. The most common break is a List field, because List is read-only, not
immutable.
What "stability" buys you
When Compose decides whether to re-run a composable, it compares each parameter to its previous value. It can only skip if it knows two things: that comparison is meaningful, and that it will be told if the value changes later. A type that satisfies both is stable.
Unstable parameters mean the composable re-runs whenever its parent does, regardless of whether anything relevant changed. That's the recomposition cost most people go hunting for.
The two contracts, precisely
@Stable — the type may change over time, but:
- Changes are made visible to Compose (through
State,mutableStateOf, or a snapshot-aware type). equals()is consistent: for the same two instances, it always returns the same result.- Public properties behave the same way.
@Immutable — stronger: no property will ever change after construction. Nothing to notify,
because nothing can happen. Compose can skip more aggressively.
Neither is checked. The compiler takes your word for it, everywhere, forever.
The List trap
This is the single most common way the promise gets broken:
@Immutable
data class Filters(
val query: String,
val tags: List<String>, // read-only interface, not an immutable type
)
List<String> guarantees you can't mutate it through that reference. It says nothing about whoever
constructed it. Pass an ArrayList that a repository still holds, mutate it there, and:
- The
Filtersinstance isequals()to its previous self — same reference, same contents by identity. - You told Compose it can never change.
- So the composable never recomposes, and the UI shows stale filters.
No exception, no log line. Just wrong pixels.
The fix is a type that actually means it:
// kotlinx-collections-immutable
@Immutable
data class Filters(
val query: String,
val tags: ImmutableList<String>,
)
val filters = Filters("kotlin", persistentListOf("android", "compose"))
Don't annotate to silence a warning
The pattern I most want to talk teams out of:
// "Compose said this was unstable, so I annotated it."
@Stable
data class CartState(var itemCount: Int) // var, with no notification mechanism
That var changes without telling Compose anything. The annotation doesn't make the type stable — it
makes the compiler stop protecting you from it. The stability inference was correct; the type is the
problem.
If you're annotating in response to a warning rather than because you can state the guarantee out loud, you're converting a performance issue into a correctness issue.
Verify, don't guess
The Compose compiler will tell you what it inferred. Enable the metrics reports:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
You get a per-class stability breakdown and a per-composable report showing which parameters are stable and whether the function is skippable and restartable. That file answers "is this actually stable?" definitively, which no amount of reading the source will.
Look for composables marked restartable but not skippable — that combination is usually one
unstable parameter away from being fixed, and it's where the cheap wins are.
The rule
You cannot annotate your way to correctness. @Stable and @Immutable are you telling the compiler
"trust me" — so the only question worth asking before you add one is whether you can state the
guarantee precisely, for every field, including the ones someone else constructed.
When in doubt, leave the annotation off. An unstable type costs you recomposition. A falsely stable type costs you a bug that reproduces once a week and never in a debugger.