Engineering

Your data class is unstable and Compose told you by recomposing everything

A List parameter is unstable because the compiler cannot prove it will not change.

MobileByteSensei 8 min read
composeperformanceandroid

TL;DR

Compose skips a composable whose parameters are unchanged — but only for parameters it can prove are stable. Stability is a compile-time property with a precise definition, and a surprising amount of ordinary Kotlin fails it.

List, Map, Set and Flow are interfaces whose implementations might be mutable, so the compiler cannot prove immutability. A data class with one List field is unstable, and so is every composable taking it.

The compiler will tell you, in a report you have to ask for. -Pcompose.metrics emits a file naming every unstable class and every unskippable composable in your module, which is the only place this is visible.

The case this post is about

A model that looks entirely immutable:

data class Dashboard(
    val title: String,
    val widgets: List<Widget>,            // ← unstable
    val lastSync: Instant,                // ← also unstable, for a different reason
    val onRefresh: () -> Unit,            // ← stable, surprisingly
)

Every property is a val. Nothing mutates. A reviewer would call this immutable, and the compiler calls it unstable.

The symptom

Nothing skipping, anywhere:

Layout Inspector, change the title only
composable        recompositions   skips
DashboardScreen         18             0
WidgetGrid              18             0
WidgetCard             216             0      ← 12 widgets × 18

And the compiler's own report, which is where the answer is:

$ ./gradlew assembleRelease -Pcompose.metrics=true -Pcompose.reports=true
$ cat build/compose-reports/app_release-classes.txt

unstable class Dashboard {
  stable val title: String
  unstable val widgets: List<Widget>
  unstable val lastSync: Instant
  stable val onRefresh: Function0<Unit>
  <runtime stability> = Unstable
}

unstable class Widget {
  stable val id: String
  unstable val series: List<DataPoint>
  <runtime stability> = Unstable
}
$ cat build/compose-reports/app_release-composables.txt

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun WidgetCard(
  unstable widget: Widget
)

restartable but effectively never skipped, because its only parameter is unstable. Two surprises in that report: Instant is unstable, and the lambda is stable.

Why the obvious fix failed

"But every field is a val." val means the reference cannot be reassigned. It says nothing about whether the referenced object can change. val widgets: List<Widget> can hold a MutableList that someone else mutates.

"Add @Immutable to everything." This works and it is a promise, not a check. I annotated Dashboard while widgets was still a plain List, and a repository that reused and mutated a backing list produced a UI that silently did not update — a worse bug than the recomposition, and much harder to find.

"Use @Stable instead." A weaker promise with a different contract (properties may change, but you must notify composition). It also compiles, also silences the report, and also lies if the backing list mutates.

"Switch List to Array." Array is mutable by definition, so still unstable.

"Make the widgets a String of JSON." We genuinely discussed this. It solves stability by destroying the model, which is the point at which it is worth understanding the actual rule.

The actual mechanism

The Compose compiler classifies every type as stable, unstable, or stability-depends-on-type- parameters. A type is stable when:

All public properties are val of stable types, and

equals is consistent — equal values stay equal, so comparison is meaningful, and

Any change to a public property notifies composition (which is how MutableState qualifies as stable despite being mutable).

Then:

Primitives, String, function types and enums are stable. Function types are stable because Compose's compiler memoises lambdas it creates — which is why onRefresh came out stable.

Collection interfaces are unstable. List is an interface. Nothing stops an implementation from mutating, so there is no proof available. kotlinx.collections.immutable.ImmutableList is marked @Immutable by its library, which supplies the proof by declaration.

Classes from modules not compiled by the Compose compiler are unstable. This is the Instant surprise: kotlinx-datetime is not built with the Compose compiler plugin, so the plugin cannot inspect it and assumes the worst. The StableConfig file exists for exactly this case.

And instability propagates. One unstable property makes the class unstable; one unstable parameter makes a composable unskippable.

The Compose compiler's stability classification: primitives, String, enums and function types proven stable; collection interfaces unstable because an implementation may mutate; classes from modules not compiled by the plugin unstable because they cannot be inspected — with instability propagating from one property to its class and from one parameter to its composable

Stability is a proof the compiler can construct, not a property you can see by reading. val everywhere is not a proof. @Immutable is a promise substituted for a proof — correct when you honour it and silently catastrophic when you do not.

"Silently catastrophic" is worth making concrete, because the failure does not look like a stability problem. @Immutable tells the compiler it may skip recomposition whenever equals says the value is unchanged. Put it on a type that can change and you have authorised Compose to ignore a real update:

@Immutable                                   // a lie: the list below is mutable
data class Filters(val selected: MutableList<String>)

// elsewhere
filters.selected.add("urgent")               // mutates in place — `equals` is unchanged
viewModel.refresh()                          // same Filters instance emitted

The chip never appears. Nothing throws, nothing logs, and the Layout Inspector shows the composable simply not recomposing — which reads as "the state did not update" and sends you to the ViewModel, where the list demonstrably does contain "urgent". The state layer is correct and the UI is stale, which is the most expensive shape a bug can have.

The asymmetry is what makes this worth internalising. A missing stability annotation costs performance, shows up in the metrics report, and is found by profiling. A wrong one costs correctness, shows up nowhere, and is found by a user. So the ordering is: fix the real proofs first, reach for @Immutable last, and never put it on a type holding a mutable reference — not even behind a val, because val protects the binding and not the contents.

The fix

Supply real proofs where you can, and declared ones only where the type genuinely cannot change.

// 1. ImmutableList: a proof, enforced by the type
data class Dashboard(
    val title: String,
    val widgets: ImmutableList<Widget>,
    val lastSync: Instant,
    val onRefresh: () -> Unit,
)

data class Widget(
    val id: String,
    val series: ImmutableList<DataPoint>,
)
// the mapper converts once, at the boundary — never inside a composable
fun DashboardDto.toModel(onRefresh: () -> Unit) = Dashboard(
    title = title,
    widgets = widgets.map { it.toWidget() }.toImmutableList(),
    lastSync = Instant.parse(syncedAt),
    onRefresh = onRefresh,
)
# 2. StableConfig for types from modules the plugin cannot inspect
# compose_compiler_config.conf
kotlinx.datetime.Instant
kotlinx.datetime.LocalDate
// build.gradle.kts
composeCompiler {
    stabilityConfigurationFile = rootProject.file("compose_compiler_config.conf")
}

Three judgements in there worth stating:

ImmutableList over @Immutable on the class. The type enforces what the annotation would merely assert. This is the difference between the fix and the bug I shipped earlier.

Instant goes in StableConfig, not behind an annotation, because it is genuinely immutable and the only problem is that the plugin cannot see it. This is the legitimate use of the trust-me mechanism: an external type you have verified.

Conversion at the mapper, not in composition. widgets.toImmutableList() inside a composable allocates a new list each call, producing a new unequal value and defeating skipping — the over-eager failure in a different costume.

And @Immutable reserved for a case where you own and control everything:

@Immutable                 // PROMISE: nothing here ever mutates. Verified by review.
data class ChartTheme(
    val lineColor: Color,
    val gridColor: Color,
)

Then make it a gate, because this regresses silently:

# CI
./gradlew assembleRelease -Pcompose.metrics=true -Pcompose.reports=true
UNSTABLE=$(grep -c '^unstable class' build/compose-reports/*-classes.txt || true)
[ "$UNSTABLE" -le 0 ] || { grep '^unstable class' build/compose-reports/*-classes.txt; \
  echo "❌ $UNSTABLE unstable UI model class(es)"; exit 1; }

A gate on our own UI models. Third-party unstable types will always exist; the thing worth holding is that the classes you pass to composables are provably stable.

How we proved it

The compiler report, before and after — the primary evidence, because it is the compiler stating its own conclusion:

unstable class count (our UI models)
  before:  6
  after:   0

unskippable composables taking our models
  before:  11
  after:    0

Recomposition counts, same title change:

composable        before   after
DashboardScreen    18/0     18/0      ← reads the title; correctly recomposes
WidgetGrid         18/0      0/18
WidgetCard        216/0      0/216

WidgetCard from 216 executions to 216 skips is the measurement.

Frame timing:

                      before     after
frame P50              8.4ms     6.5ms
frame P95             46.1ms     8.2ms

And the @Immutable-lie bug, reproduced deliberately so the risk is documented rather than asserted:

// @Immutable on Dashboard while `widgets` is a plain List,
// and a repository that mutates its backing list in place
repository.widgets.add(newWidget)        // same list instance
widget count in the model:  13
widgets rendered:           12      ← no recomposition; the reference never changed

A UI silently one item behind, with no error anywhere. That is the failure mode of a promise the compiler trusted and the code broke, and it is the reason the fix uses a type rather than an annotation.

One limit: the gate counts unstable classes in the report, which includes none of the third-party types that remain unstable and unconfigured. It holds the line on our models and does not prove the whole tree is skippable — and with strong skipping enabled in current compiler versions, some unstable parameters skip anyway when passed as the same instance, so the performance delta on a modern setup will be smaller than these numbers.

What this generalizes to

val is about the reference; immutability is about the object. This distinction exists in every language with references, and it is the single most common source of "but it's immutable" arguments. A read-only reference to a mutable thing is a mutable thing.

An annotation that substitutes a promise for a proof will eventually be wrong. @Immutable, @ThreadSafe, unsafe, @SuppressWarnings — each is a human assertion the compiler cannot check. Prefer a type that enforces the property; use the annotation only where no such type exists and you have verified the claim.

Ask the compiler what it concluded. The stability report, a type-check trace, an optimisation log, an explain plan — tools that expose what the machine decided beat reasoning about what it should have decided. Most of this post was reading one generated file.

Convert at the boundary, once. Mapping to the stable type belongs where data enters the system. Doing it at the point of use allocates a new value each time and undoes the thing you were trying to achieve.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS