Compose Stability Analyzer: How Compose Decides Your Class Is Unstable, and What It Costs You
Compose Stability Analyzer: How Compose Decides Your Class Is Unstable, and What It Costs You
You open a composable and the tooling tells you a parameter is unstable. The instinct is to treat that as a defect and go fix it: wrap the list, add @Immutable, make the var a val.
That instinct was right three years ago. Strong skipping changed what the verdict means, and most of the advice written before it is now answering a question nobody is asking. An unstable parameter no longer stops a composable from skipping. It changes how that parameter is compared, which sometimes costs you every frame and sometimes costs you nothing at all.
This article is about the inference that produces the verdict, and about telling those two cases apart. Compose Stability Analyzer is a useful lens for it, because it is the only tool that shows the verdict, the reason behind it, and what actually happened at runtime in the same place. Where the article makes a claim about Compose itself, it is checked against the Compose compiler source rather than taken on trust.

You can install the plugin from the JetBrains Marketplace, or through Android Studio > Settings > Plugins > Marketplace. The Gradle half is
com.github.skydoves.compose.stability.analyzer, and setup is in the documentation.
What the verdict means now
Before strong skipping, one unstable parameter made a composable non-skippable. Stability decided whether a function could be skipped, so an unstable verdict really was a performance bug.
With strong skipping on, which is the default, a restartable composable is skippable regardless of its parameter types. You can see the change stated plainly in how the plugin computes its own skippable flag:
// Check if all parameters AND receivers are stable
val isNaturallySkippable =
parameters.all { it.stability == ParameterStability.STABLE } &&
receivers.all { it.stability == ParameterStability.STABLE }
// In strong skipping mode, ALL composables are skippable
val isStrongSkippingEnabled = settings.isStrongSkippingEnabled
val isSkippable = if (isStrongSkippingEnabled) {
true
} else {
isNaturallySkippable
}
Stability did not stop mattering. It moved. It now decides how each parameter is compared when Compose asks whether anything changed:
- a stable parameter is compared structurally, with
equals() - an unstable parameter is compared by identity, with
===
That single sentence explains every confusing stability story you have ever read. An unstable parameter that keeps arriving as the same instance passes its === check and skips perfectly well. An unstable parameter rebuilt at the call site fails === on every recomposition, even when the new object is equals to the old one, and the composable re-executes forever.
Same compiler verdict. Opposite consequences. Which is why the first useful question is not "is this unstable" but "where does this value come from".
The rules that produce the verdict
Stability inference is a recursive walk. Given a type, decide whether values of that type can change out from under Compose. Most of it is unsurprising: primitives and String are stable, a var is not, a MutableList is not.
The part worth knowing is that the answer is not a boolean. There are four:
internal fun toParameterStability(): ParameterStability {
return when (this) {
is Certain -> if (stable) ParameterStability.STABLE else ParameterStability.UNSTABLE
is Runtime -> ParameterStability.RUNTIME
is Unknown -> ParameterStability.UNKNOWN
is Parameter -> ParameterStability.RUNTIME
is Combined -> {
val stabilities = elements.map { it.toParameterStability() }
when {
stabilities.all { it == ParameterStability.STABLE } -> ParameterStability.STABLE
stabilities.any { it == ParameterStability.UNSTABLE } -> ParameterStability.UNSTABLE
else -> ParameterStability.RUNTIME
}
}
}
}
RUNTIME means the answer depends on a type the compiler cannot see yet, such as the element type of a List. UNKNOWN means the concrete implementation is not knowable at all, which is what an interface gets. Both behave like unstable at the comparison site, but they are different diagnoses and they have different fixes.
Now the rules that actually surprise people.
A delegated var does not destabilize its class
A var normally makes a class unstable. This one does not:
class SearchState {
var query by mutableStateOf("")
}
The exemption is one predicate, and the second half of it is the whole story:
val mutableProperties = properties.filter { !it.isVal && !it.isDelegatedPropertyCompat() }
A delegated property has no mutable field of its own. What it has is a MutableState, which is @Stable and notifies Compose when it changes. Scoring the delegate instead of the property is what makes the whole state-holder pattern work.
Drop the by and write val query: MutableState<String> instead, and you get the opposite result for a subtle reason: the property is now a val holding a stable type, so the class stays stable, but you have also given up the notification-shaped ergonomics. The verdict is the same; the code is worse.
A computed property is not scored at all
data class User(
val first: String,
val last: String,
) {
val display: Spanned get() = SpannableString("$first $last")
}
Spanned is unstable. User is not, because a computed getter has no backing field and stores nothing:
// Get all state-storing properties from the class. Computed getter-only properties have no
// backing field, store no state, and are ignored — matching the Compose compiler (issue #178).
val properties = classSymbol.declaredMemberScope.callables
.filterIsInstance<KaPropertySymbol>()
.filterNot { it.isComputedGetterOnly() }
.toList()
This is worth internalising because it inverts a common instinct. People add get() to dodge a stability warning and assume they are cheating. They are not: a value that is recomputed on read is not state the class holds, so it genuinely cannot make the class change out from under Compose.
An open class is not a dead end, but its fields still bind you
Pass an abstract or open class as a parameter and the concrete subtype is unknown, so the honest verdict is UNKNOWN. But its fields still exist in every subclass, so the analyzer walks them first:
if (!hasStabilityAnnotation) {
val fieldStability = analyzeClassProperties(classSymbol, currentlyAnalyzing)
// No destabilizing state → the concrete subtype is still unknown → UNKNOWN. Otherwise
// (a var / unstable / runtime field) propagate that verdict as-is: it holds regardless
// of the concrete subtype, which is exactly what lets a subclass inherit the instability.
return if (fieldStability.isStable()) {
KtStability.Unknown(fqName ?: simpleName)
} else {
fieldStability
}
}
The practical consequence: a var on an abstract base makes every subclass unstable, and no amount of care in the subclass will fix it.
Interfaces are unknown, and that is not the same as unstable
An interface parameter cannot be scored, because the implementation is chosen at the call site. The verdict is UNKNOWN. The fix is not to annotate the interface, it is to accept that you have traded a compile-time answer for flexibility, and if that parameter is hot, to take a concrete type instead.
Three kinds of collection, three different answers
| Type | Verdict |
|---|---|
MutableList, MutableSet, MutableMap | UNSTABLE |
ImmutableList, PersistentList, and the rest of kotlinx.collections.immutable | STABLE |
List, Set, Map | RUNTIME |
The middle case is why kotlinx.collections.immutable keeps coming up in Compose performance advice. The third is the one people misread: a List<String> is not unstable, it is RUNTIME. The interface is read-only but the runtime instance might be an ArrayList, so Compose defers. It ends up compared by identity like an unstable parameter, which is why a freshly built list still costs you.
Value classes, enums, and objects
A value class takes the stability of what it wraps, so @JvmInline value class UserId(val raw: String) is stable and value class Holder(val items: MutableList<String>) is not. Enums are always stable. So are objects, and the reason is worth stating: an object is a singleton, so its identity never changes, and no property it holds can make a parameter of its type differ between two recompositions. The Compose compiler short-circuits on exactly that, one line after the enum case:
if (declaration.isEnumClass || declaration.isEnumEntry) return Stability.Stable
if (declaration.isObject) return Stability.Stable
Anything from another module is unstable until proven otherwise
This is the rule that produces the most confusing warnings, and it is deliberate conservatism rather than a Compose rule:
if (isFromDifferentModule(classSymbol)) {
val stabilityInferredParams = getStabilityInferredParameters(classSymbol)
if (stabilityInferredParams == null) {
return KtStability.Certain(
stable = false,
reason = "External class without stability annotation",
)
}
// ...
}
A perfectly immutable data class in your :core:model module comes back unstable in your :feature:home module if :core:model does not run the Compose compiler. That is the single most common cause of a surprising verdict, and the fix is usually to apply the Compose compiler plugin to the model module so it emits the annotation that carries the answer across the boundary.
Those verdicts land next to each parameter as inline hints, which is the fastest way to see the four-way distinction in practice:

Sometimes it is not the class at all
Before blaming a type, check that the function was ever a candidate for skipping. Only a composable with a restart group can be skipped, and Compose's rules for emitting one have nothing to do with stability. Here is the compiler's own shouldBeRestartable():
protected fun IrFunction.shouldBeRestartable(): Boolean {
// Only insert observe scopes in non-empty composable function
if (body == null || this !is IrSimpleFunction)
return false
// ...
// Do not insert observe scope in an inline function
if (isInline)
return false
// ...
// Do not insert an observe scope if the function has a return result
if (!returnType.isUnit())
return false
// ...
// Open functions cannot be restartable since restart logic makes a virtual call (todo: b/329477544)
if (modality == Modality.OPEN && parentClassOrNull?.isFinalClass != true) {
return false
}
// ...
}
Read the last clause carefully, because it is expensive and invisible. A @Composable member with a body on an interface is open by definition:
interface Screen {
@Composable
fun Content(state: UiState) { /* ... */ }
}
That function has no restart group. It re-executes every time its parent recomposes, no matter how stable UiState is. Marking it final in the implementing class restores it.
One more myth worth killing: @ReadOnlyComposable appears nowhere in that function. The familiar read-only composables lose their restart group to !returnType.isUnit(), because they return a value. The annotation is not the cause. The plugin notes this explicitly, and the compiler source confirms it.
When a composable is not restartable, the plugin stops and reports no parameter verdicts at all, because their stability cannot change the outcome.
Where the verdict comes from, and why two tools can disagree
Here is the part that makes the plugin interesting rather than just convenient.
The Compose compiler computes stability during a build. An IDE plugin cannot run Compose lowering on your open file, so to answer as you type it has to reimplement the algorithm, on top of the Kotlin Analysis API, walking symbols instead of IR. Every rule quoted above is that reimplementation.
A reimplementation has to agree with the original, and getting it wrong is quiet. One example is worth the whole section.
@StabilityInferred is how the Compose compiler writes a class's stability into a binary so other modules can read it. Its parameters argument looks like a boolean and is not one. The bits from 0 to n-1 mark which of the class's n type parameters its stability depends on, and the bit at index n is a separate "known stable" sentinel. For a class with no type parameters that sentinel is simply bit 0, so parameters = 1 means stable and parameters = 0 means not stable.
Read it as parameters == 0 -> STABLE, which is the obvious reading, and you get the exact opposite answer. That is the one direction that misleads: a cross-module class the compiler had marked unstable came back STABLE, and the tool whose job is to warn you went quiet. The project's own comment records that it was verified against real bytecode, StableUser = 1 and UnstableUser = 0.
The corrected decode is the same expression in both of the project's implementations:
typeParameterCount < 32 && ((bitmask shr typeParameterCount) and 1) == 1
There is a subtler agreement problem that both implementations solved by giving something up. For a class in the module being compiled, @StabilityInferred only exists after the Compose compiler's own lowering has run, and nothing pins the order plugins run in. So both sides refuse to read it there, and only trust it on classes that arrive from a binary, which is the channel it was designed for.
The bug that forced the rule is a good one. Reading the annotation on a source class made the verdict depend on the resolved order of kotlinCompilerPluginClasspath, so the same code could be scored differently on two machines. A verdict was available on one side and unavailable on the other, and rather than let the editor and the build contradict each other, both dropped it.
One place they still disagree
Nothing enforces the agreement, and at least one difference remains. Both the plugin and the Compose compiler guard against recursive types, and they resolve the cycle in opposite directions. Compose calls it unstable:
if (currentlyAnalyzing.contains(symbol)) return Stability.Unstable
The plugin calls it stable:
// Check for circular references
if (declaration in currentlyAnalyzing) {
return KtStability.Certain(
stable = true,
reason = StabilityConstants.Messages.CIRCULAR_REFERENCE,
)
}
For class Node(val value: Int, val next: Node?), the gutter and the build report will not match. Neither is obviously wrong. Compose is conservative and accepts that it may mark some genuinely stable recursive types unstable; the plugin avoids a scary warning on a shape that is usually fine. It is a good reminder that a static verdict is a model, not a measurement.
Which unstable warnings actually cost you
Back to the opening question. You have a verdict. Does it matter?
Static analysis cannot tell you, because the answer depends on where the value comes from at the call site, not on the type. What it can do is watch. The plugin's runtime records, for each parameter on each recomposition, whether the value changed structurally and whether the instance changed:
val hasPrevious = name in previousParameters
val previousValue = previousParameters[name]
val changed = hasPrevious && previousValue != value
val referenceChanged = !isStable && hasPrevious && !changed && previousValue !== value
Every clause in that last line is load-bearing. !isStable is the gate: the identity check runs only for parameters the inference called unstable, because those are the only ones strong skipping compares with ===. It is also what keeps the reading honest, since a boxed Int outside the JVM's small-integer cache is a new instance despite being equal, and without the gate every stable primitive would report a phantom identity change forever. hasPrevious suppresses the first composition, which has nothing to compare against, and !changed suppresses genuine changes, which are already accounted for. What survives all four conditions is the narrow case the compiler could never have predicted: same value, new object.
Put the static verdict and the runtime observation side by side and one warning splits into three:
// Unstable params are compared by identity (===): a fresh equals-equal instance recomposes.
ParameterStability.UNSTABLE -> when {
equalsChanged > 0 -> RealityGrade.JUSTIFIED
refChanged > 0 -> RealityGrade.SILENT_WASTE
else -> RealityGrade.FALSE_ALARM
}
- Justified. The value genuinely changed. The recomposition was work you asked for. Nothing to fix.
- Silent waste. The value was
equals-equal but arrived as a new instance, so===failed and the composable re-executed for nothing. This is the real bug, and it is invisible to the compiler report. - False alarm. The parameter was unstable and nothing changed. The warning is noise. Ignore it.
Three outcomes, one compiler verdict. That is the practical shape of stability work after strong skipping, and it is why the triage question is "does this allocate at the call site" rather than "is this type annotated". The grading and the fixes for each is its own topic, as is the
mechanism of the comparison itself.
Conclusion
In this article, you've followed a stability verdict from the rule that produced it to the cost it does or does not impose.
The rules are more forgiving than their reputation. A delegated var is fine. A computed property is not state. An enum, an object, a value class wrapping a String are all stable. What genuinely bites is narrower than the folklore: a real var field, a mutable collection, an unannotated type from a module that does not run the Compose compiler, and an abstract base whose fields leak instability into every subclass.
And the verdict itself is no longer a verdict on performance. Strong skipping turned it into a statement about comparison, which means the same "unstable" label covers a composable that skips flawlessly and one that recomposes every frame. Distinguishing them takes an observation, not an inference, which is the one thing a build report cannot give you.
If you take one habit from this, make it the triage question. When you see an unstable parameter, do not reach for @Immutable. Look at the call site and ask whether that value is rebuilt on every pass. If it is hoisted or remembered, the warning is free. If it is allocated inline, you have found something worth fixing, and you would never have known which from the type alone.
As always, happy coding!

