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.

Gutter icons showing stability status for each composable

Hovering over the gutter icon shows a detailed tooltip with the stability status of each parameter, including the reason:

UserCard(user: User)
  skippable: false
  restartable: true
  params:
    - user: UNSTABLE (has mutable property: 'address')

This instant feedback means you catch instability as you write code, not after performance degrades in production.

The Stability Explorer tool window provides a project wide view organized by module, package, file, and composable.

Stability Explorer showing project wide composable stability

You can filter to show only non skippable composables and navigate directly to the source. For large projects, this is the fastest way to audit stability across hundreds of composables.

The Recomposition Cascade Visualizer shows downstream impact. Right click any composable and select "Analyze Recomposition Cascade" to see every downstream composable that will reexecute when this one recomposes.

Recomposition Cascade showing downstream impact

The visualizer reports total downstream count, skippable vs non skippable counts, and maximum depth. This makes the cascade effect concrete: you can see exactly how many composables are affected by a single unstable parameter.

Runtime tracing with @TraceRecomposition

Static analysis tells you what could happen. Runtime tracing tells you what actually happens. The @TraceRecomposition annotation instruments a composable to log every recomposition with detailed parameter information:

@TraceRecomposition(tag = "products", threshold = 2)
@Composable
fun ProductList(items: List<Product>, onItemClick: (Product) -> Unit) {
    // ...
}

When this composable recomposes more than twice (the threshold), it logs output like:

D/Recomposition: [Recomposition #5] ProductList (tag: products)
D/Recomposition:   ├─ [param] items: List<Product> unstable (List@abc)
D/Recomposition:   ├─ [param] onItemClick: Function1 stable
D/Recomposition:   └─ Unstable parameters: [items]

The log shows exactly which parameters are unstable and which ones changed. The threshold parameter filters out initial compositions so you only see repeated recompositions that indicate a performance problem. Setting traceStates = true also tracks internal mutableStateOf changes within the composable.

The IDE plugin's Live Heatmap connects to a running device and displays recomposition counts as color coded overlays directly in the editor.

Live Heatmap showing recomposition counts in the editor

Composables with fewer than 10 recompositions appear green (normal). Between 10 and 50 appears yellow (worth investigating). Above 50 appears red (likely a performance issue). This bridges the gap between static analysis and runtime behavior.

Making types stable

Once you have identified unstable types, the fixes follow a small set of patterns.

Immutable collections

The most common source of instability is Kotlin's standard library collection interfaces. List<T>, Set<T>, and Map<K, V> are interfaces, and the compiler cannot prove that the underlying implementation is immutable. The fix is to use ImmutableList, ImmutableSet, or ImmutableMap from kotlinx.collections.immutable:

// Before: unstable, List is an interface
data class UiState(
    val items: List<Item>,
    val tags: Set<String>,
)

// After: stable, ImmutableList/ImmutableSet are immutable types
data class UiState(
    val items: ImmutableList<Item>,
    val tags: ImmutableSet<String>,
)

From the compiler's perspective, ImmutableList is a concrete type registered in the known stable constructs table. It resolves to Stability.Stable when the element type is also stable.

If migrating to immutable collections is not feasible across your codebase, you can declare standard collections as stable via the stability configuration file. Create a stability-config.conf and reference it in your Gradle configuration:

kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map

This tells the compiler to treat these types as stable. The trade off is that you are making a promise: if you pass a MutableList where the compiler expects stability, updates to that list will not trigger recomposition, and your UI will be stale.

Properties must be val

Any var property in a class makes the entire class unstable. The compiler's logic is straightforward: if a property can be reassigned, the value might change between recompositions without Compose being notified.

// Before: unstable, var properties
data class UserState(var name: String, var age: Int)

// After: stable, all val properties
data class UserState(val name: String, val age: Int)

This applies to all properties, including inherited ones. If a superclass declares a var property, every subclass is also unstable.

@Stable and @Immutable annotations

When the compiler cannot infer stability automatically, you can annotate the type manually. The two annotations serve different purposes:

@Immutable is a promise that the type's observable state never changes after construction. The compiler treats @Immutable types as stable and also enables additional optimizations: constructors with constant arguments can be treated as static expressions, which avoids redundant remember calls.

@Stable is a weaker promise: the type's state may change, but all changes will be observed by Compose through the snapshot system (e.g., properties backed by mutableStateOf). Use @Stable for types that wrap reactive state:

@Stable
class CounterState {
    var count by mutableStateOf(0)
        private set
    fun increment() { count++ }
}

Both annotations are contracts with the compiler. If you annotate a type as @Immutable but it actually mutates, Compose will skip recompositions it should not skip, and your UI will display stale data. There is no runtime enforcement.

External and third party types

Types from modules that are not processed by the Compose compiler lack stability metadata and default to unstable. This includes types from plain Kotlin/Java libraries, Protobuf generated classes (with some exceptions), and model classes in shared non Compose modules.

There are two solutions. The first is the stability configuration file, which supports wildcard patterns:

com.example.network.models.**
com.squareup.moshi.JsonAdapter

The second is wrapping the external type in a stable data class:

@Immutable
data class StableTimestamp(val millis: Long)

// Convert at the boundary
fun Instant.toStable() = StableTimestamp(toEpochMilli())

The configuration file also supports generic parameter control. In the pattern com.example.Container<*,_>, * marks a type parameter as stable regardless of its actual type, while _ means that parameter's stability still matters for the overall result.

Stabilizing lambda parameters

Function types are inherently stable in Compose. A lambda like () -> Unit or (Int) -> String is always stable because function types are immutable. However, a lambda that captures an unstable value forces the compiler to create a new lambda instance on every recomposition, because the captured reference might have changed:

// Before: new lambda every recomposition because 'items' is unstable
@Composable
fun Screen(items: List<Item>) {
    val viewModel = viewModel<MyViewModel>()
    ItemList(
        items = items,
        onClick = { item -> viewModel.select(item) }
    )
}

The fix is to ensure the captured values are stable. If the lambda only captures stable references (the ViewModel is stable, and the parameter is stable), the compiler can memoize it:

// After: stable captures, lambda can be memoized
@Composable
fun Screen(items: ImmutableList<Item>) {
    val viewModel = viewModel<MyViewModel>()
    ItemList(
        items = items,
        onClick = { item -> viewModel.select(item) }
    )
}

When stable captures are not possible, you can use remember to hold a reference:

val onClick = remember { { item: Item -> viewModel.select(item) } }

Strong Skipping Mode (enabled by default in recent Compose compiler versions) provides a safety net by comparing unstable lambda parameters using referential equality (===). If the same lambda instance is passed, the composable skips even without stability proof. This helps in practice, but relying on it masks underlying instability that may cause issues in other contexts.

Reading state in the right phase

Compose processes each frame in three phases: Composition, Layout, and Drawing. Which phase reads a state value determines how much work a state change triggers.

A state read during composition triggers recomposition, the most expensive response because it reexecutes composable functions and may cascade down the tree. A state read during layout triggers only relayout, skipping composition. A state read during drawing triggers only a redraw, the cheapest response because it skips both composition and layout.

Consider animating a horizontal offset:

// Before: state read during composition, triggers recomposition
val offset by animateFloatAsState(targetValue)
Box(modifier = Modifier.offset(x = offset.dp))

The offset value is read during composition because Modifier.offset(x = ...) evaluates its argument at composition time. Every frame of the animation recomposes the composable.

// After: state read during draw, triggers only redraw
val offset by animateFloatAsState(targetValue)
Box(modifier = Modifier.graphicsLayer { translationX = offset })

The graphicsLayer lambda executes during the draw phase. The offset read is observed only at the draw level, so the animation runs without triggering composition or layout. The visual result is identical, but the performance cost is significantly lower.

The same principle applies to Modifier.offset. The lambda version Modifier.offset { IntOffset(x, 0) } defers the offset calculation to the layout phase, while the value version Modifier.offset(x.dp, 0.dp) reads during composition. Prefer the lambda version for values that change frequently.

Filtering recompositions with derivedStateOf

Sometimes a composable depends on a value derived from state, but the derived value changes less frequently than the source. Without derivedStateOf, the composable recomposes every time the source changes, even when the derived result is identical:

// Before: recomposes on every scroll position change
@Composable
fun Header(scrollState: ScrollState) {
    val showElevation = scrollState.value > 0
    Surface(shadowElevation = if (showElevation) 4.dp else 0.dp) {
        // ...
    }
}

The scrollState.value changes on every pixel of scrolling, but showElevation only changes when crossing the threshold (from zero to nonzero or back). Wrapping the derivation in derivedStateOf ensures downstream composables only recompose when the derived value actually changes:

// After: recomposes only when showElevation toggles
@Composable
fun Header(scrollState: ScrollState) {
    val showElevation by remember {
        derivedStateOf { scrollState.value > 0 }
    }
    Surface(shadowElevation = if (showElevation) 4.dp else 0.dp) {
        // ...
    }
}

derivedStateOf caches the result and compares it using structural equality. If the new result equals the previous one, it does not notify observers, and no recomposition occurs. This is ideal for threshold checks, filtered lists, and any computation where the output changes less frequently than the input.

One caveat: do not wrap cheap reads that already change at the same rate as their source. Wrapping derivedStateOf { count + 1 } adds caching overhead without filtering any recompositions, because the derived value changes every time count changes.

Enforcing stability in CI/CD

Fixing stability issues once is not enough. As codebases evolve, new composables and types are added, and stability can regress silently. The Compose Stability Analyzer Gradle plugin provides CI/CD integration to catch these regressions before they merge.

Generating a stability baseline

Stability Validation provides the stabilityDump task, which analyzes every composable in the project and generates a .stability file listing each function's stability status:

./gradlew stabilityDump

The output for each composable includes its skippable/restartable status and the stability of each parameter:

@Composable
public fun com.example.ItemCard(item: com.example.Item): kotlin.Unit
  skippable: true
  restartable: true
  params:
    - item: STABLE (marked @Immutable)

Commit this file to version control. It serves as the reference baseline for future comparisons.

Validating against the baseline

The stabilityCheck task compares the current code against the committed baseline:

./gradlew stabilityCheck

It detects three types of changes: stability regressions (a parameter became less stable), new composables, and removed composables. The task fails the build when a regression is detected.

You can integrate this into a GitHub Actions workflow:

- name: Check Compose Stability
  run: ./gradlew stabilityCheck

Configure the behavior in your build file: failOnStabilityChange controls whether regressions fail the build, ignoredPackages and ignoredClasses exclude specific code, and stabilityConfigurationFiles declares external types as stable. When a regression is intentional (e.g., a type genuinely needs to become mutable), update the baseline with stabilityDump and include the change in the PR.

Conclusion

In this article, you've explored the full landscape of Compose performance optimization through stability. The runtime's skipping mechanism relies on the compiler's ability to prove parameter stability, and common patterns like mutable collections, var properties, and unstable lambda captures silently disable this optimization. Fixing these issues follows a small set of patterns: use immutable collections, keep properties as val, annotate types with @Stable or @Immutable when the compiler cannot infer stability, stabilize lambda captures, read state in the lowest possible phase, and use derivedStateOf to filter redundant updates.

Understanding these patterns is the first step, but detecting issues at scale requires tooling. The Compose Stability Analyzer provides real time feedback in the IDE through gutter icons and stability tooltips, runtime tracing through @TraceRecomposition, and project wide auditing through the Stability Explorer and Recomposition Cascade Visualizer. The Gradle plugin closes the loop by enforcing stability baselines in CI/CD, catching regressions before they reach production.

Whether you are building a new feature, optimizing an existing screen, or setting up performance guardrails for your team, the combination of stability awareness, targeted fixes, and automated enforcement gives you the tools to keep your Compose UI fast as your codebase grows.

As always, happy coding!

Jaewoong (skydoves)