Optimize App Performance by Mastering Stability in Jetpack Compose

skydovesJaewoong Eum (skydoves)||15 min read

Optimize App Performance by Mastering Stability in Jetpack Compose

Compose's performance model centers on one idea: skip work that does not need to happen. When the runtime can prove that a composable's inputs have not changed, it skips re-execution entirely. This optimization, called skipping, is what makes Compose fast by default. But small coding patterns can silently disable skipping across large sections of a UI tree, and without the right tools, these regressions are invisible until users notice dropped frames.

In this article, you'll explore the stability system that powers skipping, how the compiler infers stability for your types, common patterns that break it (mutable collections, var properties, lambda captures, wrong phase state reads), practical fixes with before and after code, how to detect instability using the Compose Stability Analyzer, and how to enforce stability baselines in CI/CD to prevent regressions from reaching production.

The fundamental problem: Invisible recomposition waste

Every composable function can be re-executed whenever the state it reads changes. When a state value changes, Compose walks the tree and re-executes every composable that depends on that state. The mechanism that makes this efficient is skipping: if a composable's parameters have not changed since the last execution, Compose skips it and reuses the previous output.

For skipping to work, two conditions must be true. First, the parameter's type must be stable, meaning the compiler can guarantee that the value's observable state will not change without Compose being notified. Second, the current value must be equal to the previous value via equals(). When both conditions hold, the composable is marked skippable, and the compiler generates a comparison check before each reexecution.

The problem arises when a parameter type is unstable. If the compiler cannot guarantee stability, it has no choice but to re-execute the composable every time, regardless of whether the actual value changed. One unstable parameter is enough to disable skipping for that composable. Worse, the effect cascades: if a parent composable re-executes, it passes new parameter instances to all its children, triggering reexecution down the entire subtree.

Consider a list screen where the data is passed as a List<Item>:

@Composable
fun ItemList(items: List<Item>) {
    LazyColumn {
        items(items) { item ->
            ItemCard(item)
        }
    }
}

List is a Kotlin interface. The compiler cannot prove that the underlying implementation is immutable because MutableList also implements List. This means items is unstable, so without Strong Skipping Mode the compiler cannot generate a skip check for ItemList. Every state change in the parent recomposes the entire list, even if the list content has not changed.

How the compiler decides stability

The Compose compiler analyzes every type used as a composable parameter and assigns it a stability classification. Understanding these classifications explains why certain patterns cause performance problems and how to fix them.

Stable by default: primitives (Int, Boolean, Float, etc.), String, Unit, function types, and enum classes are all inherently stable. The compiler recognizes these without any annotation.

Data classes with all val stable properties are inferred as stable. If every property in the class is itself stable and declared as val, the compiler marks the class stable automatically:

data class User(val name: String, val age: Int) // stable: all val, all primitive/String

Unstable by default: any class with a var property is immediately unstable, because the value can change without Compose being notified. Kotlin standard library collection interfaces (List, Set, Map) are unstable because they are interfaces that could be backed by mutable implementations. Types from external modules that were not processed by the Compose compiler are also unstable by default.

The compiler encodes stability information into a generated $stable static field on each class. This field is a bitmask that the runtime reads to determine stability:

// What the compiler generates
@StabilityInferred(parameters = 0)
data class User(val name: String, val age: Int) {
    companion object {
        val `$stable`: Int = 0  // 0 = stable
    }
}

For generic types, the bitmask encodes which type parameters affect stability. A Wrapper<T> class is stable only if T is stable, and the bitmask records this dependency so the compiler can resolve it at the call site when the concrete type argument is known.

Detecting instability with Compose Stability Analyzer

Manually reasoning about stability across an entire project is impractical. The Compose Stability Analyzer provides tooling that makes instability visible at every level: in the editor while you code, at runtime while you test, and in CI before you merge.

IDE Plugin: Real time feedback

Compose Stability Analyzer, the Android Studio IDE plugin adds gutter icons next to every composable function in the editor. A green dot means the composable is skippable (all parameters stable). A yellow dot means stability is determined at runtime (generic parameters). A red dot means the composable is not skippable and will always reexecute.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor