5 min read

@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.

AndroidJetpack ComposePerformanceKotlinRecomposition
HR

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:

  1. Changes are made visible to Compose (through State, mutableStateOf, or a snapshot-aware type).
  2. equals() is consistent: for the same two instances, it always returns the same result.
  3. 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 Filters instance is equals() 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.

Keep reading