The compiler decides your types are unstable, and it writes down why

Compose infers stability per class and per parameter, and can emit a report saying what it concluded. Understanding the inference rules — and why List is unstable — explains most unnecessary recomposition.

5 min read
androidcomposekotlinperformance

Day 79 — The compiler decides stability, and writes down why

Day 79 of 100. Day 7 introduced stability in one paragraph; this is the paragraph expanded, because it's where most unnecessary recomposition comes from.

The symptom

A row that recomposes on every scroll frame despite unchanged data:

data class Order(
    val id: String,
    val total: Money,
    val items: List<LineItem>,      // ← the problem
)

@Composable
fun OrderRow(order: Order) { … }

Layout Inspector shows OrderRow recomposing constantly with zero skips. The Order object is identical between frames — same instance, same values — and Compose re-runs the body anyway.

Why the obvious fix fails

The obvious fix is @Immutable:

@Immutable
data class Order(val id: String, val total: Money, val items: List<LineItem>)

This works, and it's a promise you may not be able to keep. @Immutable tells the compiler "every property is immutable and this object will never change" — and items is a List interface, which could be an ArrayList someone mutates elsewhere.

If that happens, the UI silently doesn't update. You've traded a performance problem for a correctness one, and the correctness one is much harder to find.

Stability is inferred per class; one unstable property makes the whole class unstable

The actual mechanism

A type is stable if two things hold: equals is consistent with what the UI shows, and any public property change notifies the composition. The compiler infers this, and the rules are mechanical:

Stable — primitives, String, function types, enums, and any class whose properties are all vals of stable types. MutableState is stable by definition, because writing to it notifies.

Unstable — anything with a var property, anything with a property of unstable type, and — the one that surprises everyone — interface-typed properties, because the compiler can't see the implementation.

That last rule is why List<LineItem> is unstable. List is an interface; the runtime value might be an immutable listOf(…) or a mutable ArrayList the compiler can't rule out. It has to assume the worst.

One unstable property makes the entire class unstable, which makes every composable taking it unable to skip.

Read the report

None of this needs to be guessed. The compiler will tell you:

-Pandroidx.compose.compiler.plugins.kotlin.reportsDestination=build/compose-reports

Add that to a release build and you get three files. *-classes.txt is the one to read:

unstable class Order {
  stable val id: String
  stable val total: Money
  unstable val items: List<LineItem>
  <runtime stability> = Unstable
}

There's the diagnosis, printed by the build. *-composables.txt gives the other half:

restartable skippable fun OrderRow(
  stable order: Order
)

restartable means it can be re-executed independently; skippable means it can be skipped when parameters are unchanged. A composable that is restartable but not skippable will re-run every time its parent does, forever.

Making this report part of a CI job — even just diffing it — catches stability regressions before they reach a profiler. A pull request that turns a stable class unstable is a one-line diff in that file, and invisible everywhere else.

The fixes, in order of preference

1. Use an immutable collection type.

data class Order(val id: String, val items: ImmutableList<LineItem>)

kotlinx.collections.immutable provides ImmutableList/PersistentList, which are classes the compiler knows are stable. This is the honest fix: the type now says what the annotation would have promised.

2. Don't put the collection in the parameter.

Often the composable doesn't need the list — it needs a count, or a derived summary:

@Composable
fun OrderRow(id: String, total: Money, itemCount: Int) { … }

Passing the smallest thing that renders is both a stability fix and a clarity one.

3. @Immutable / @Stable, when you can honestly promise it.

@Immutable — nothing ever changes. @Stable — it may change, but changes notify the composition. Both are promises the compiler cannot verify; breaking them produces a UI that doesn't update.

They're right for a class you control whose immutability is genuine but not inferable — one holding a List you construct once and never share.

4. A stability configuration file, for types you don't own:

# stability-config.conf
java.time.LocalDate
com.thirdparty.*

Pointed at by stabilityConfigurationFile in the compiler options. Right for a value class from a library that's obviously immutable and unannotated.

The lambda case

Day 7 mentioned it; it's worth the detail because it's invisible in the report:

ItemRow(item = item, onClick = { viewModel.select(item) })

The lambda captures item. If item is unstable, the lambda is unstable, and ItemRow can never skip — even with every other parameter stable.

The fix is to capture something stable:

ItemRow(item = item, onClick = { viewModel.select(item.id) })    // captures a String

Method references (viewModel::select) are stable, and are the cleanest form when the signature allows.

What this doesn't fix

Stability governs skipping — whether a re-invoked composable executes. It does not affect invalidation — whether the parent re-runs at all.

If a parent recomposes 60 times a second because it reads a scroll offset (Day 7), making its children skippable stops them re-executing but the parent still runs 60 times. The better fix is moving the read down, and stability is the second line of defence.

Both matter. Confusing them is why "I made everything stable and it's still slow" happens. Skipping is a defence against a parent that re-runs; it is not a reason for the parent to stop.

How to prove it

The report is the proof, and the loop is short: read *-classes.txt, fix one unstable class, rebuild, read again. The line should flip to stable.

Then confirm the runtime effect in Layout Inspector — the composable that was recomposing with zero skips should now show skips climbing while recompositions stay flat during a scroll.

Doing both is worth it: the report says what can skip, and the inspector says what actually did.

One practical note on generating the report: it needs a release build to be meaningful, for Day 78's reason. A debug report will show the same stability inference but the runtime behaviour it predicts won't match what you measure, which makes the exercise confusing the first time.

What this generalizes to

The principle is the compiler already knows, and will tell you if asked. Stability inference runs on every build; the report is just that inference written down. Guessing at it, or annotating defensively, is doing by hand what the toolchain does automatically.

It's the same relationship as reading a type error rather than adding a cast. The information exists; the only question is whether you look at it.

The stability model itself is an instance of something broader: the compiler needs a promise it can check, and an interface doesn't provide one. Substituting a concrete immutable type gives it the promise honestly; an annotation gives it the promise on trust. Both work; only one stays true when someone else edits the code.

Tomorrow, Day 80: strong skipping — the compiler mode that changes these rules, and what it means for the fixes above.


Day 79 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Stability in Compose.