Compose Performance & Stability Explained
UI › Compose UI
Slow lists and dropped frames in Compose almost always trace back to the same root cause: a composable recomposing when nothing it actually reads has changed. Interviewers lean on this topic to separate people who've shipped Compose in production from people who've only followed a tutorial, because fixing it means reasoning about stability, not just reaching for remember everywhere.
Compose's answer is **skippability**. A composable can be marked skippable, meaning that if its parameters compare equal to the values from the previous composition, Compose skips re-running its body entirely and reuses what it already emitted. Skippability isn't free: it depends on every parameter being a type Compose can actually trust, a **stable** type. That single idea, stability unlocking skipping, is the thread the rest of this lesson pulls on: how the compiler infers it, what quietly breaks it, and the tools you use to catch when it isn't happening.
A type is **stable** when two things hold: its equals() is consistent, meaning two equal instances stay equal forever, and if the type can change internally, Compose is notified of every change through snapshot State. Primitives, String, function types, and @Composable lambdas are stable by definition.
The compiler also infers stability automatically for your own types in one common case: a data class whose properties are all vals of already-stable types.
// Compiler infers this as Stable, all vals, all stable property types
data class UserProfile(val name: String, val age: Int)
@Composable
fun UserCard(profile: UserProfile) { // skippable, no annotation needed
Text(profile.name)
}
No annotation required. The moment one property is a var, or its type is something the compiler can't itself prove stable, like a List, that inference breaks and you have to either restructure the type or reach for one of the annotations covered next.
Two annotations formalize the stability contract for your own types when the compiler can't infer it for you.
@Immutable promises the object's public properties **never change** after construction:
@Immutable
data class FrozenUser(val name: String, val age: Int)
@Stable allows mutation, but promises Compose is notified of every change, through snapshot State, and that equals() stays consistent:
@Stable
class LiveCounter(initial: Int) {
var count by mutableStateOf(initial)
}
Both are contracts, not something the compiler verifies beyond suppressing its own inference. Violating one, say mutating an @Immutable object's fields through reflection, silently produces missed recompositions. That's a real bug, not just a lint warning.
List, Set, and Map parameters are treated as **unstable** by default, which surprises people who assume an "immutable-looking" collection is automatically safe.
The reason is that List is an *interface*. The compiler can't prove the concrete runtime instance you pass is actually immutable, it could easily be a mutable ArrayList that gets mutated later without Compose ever finding out, so it conservatively marks the parameter unstable even if you personally never mutate it.
@Composable
fun ItemList(items: List<Item>) { /* not skippable */ }
Three ways to fix it: swap to kotlinx.collections.immutable's ImmutableList or PersistentList, which the compiler does trust; wrap the list inside your own @Immutable data class; or lean on Strong Skipping Mode, covered later in this lesson, which makes the composable skippable regardless.
The fix from the last chunk, swapping to ImmutableList, isn't the only option when the list actually needs to mutate in place. mutableStateListOf() returns a **SnapshotStateList**, a mutable list backed by Compose's snapshot system, the same mechanism behind mutableStateOf. Every add, remove, or set on it goes through a snapshot write, so Compose is notified of the change exactly the way it would be for any other observed state, which is why the compiler trusts it as stable even though it's mutable.
val items = remember { mutableStateListOf("A", "B", "C") }
items.add("D") // Compose observes this, recomposes correctly
val items2 = remember { mutableListOf("A", "B", "C") }
items2.add("D") // invisible to Compose, no recomposition
A plain mutableListOf() ArrayList gives the opposite guarantee: mutating it never touches snapshot state, so Compose has no way to know anything changed, and any composable reading it stays silently stale.
Rendering a LazyColumn doesn't just care whether items are stable, it also needs a way to tell items apart across data changes: one added at the top, one removed from the middle, two swapped. Without a key, Compose falls back to matching items by their position in the list, so shifting everything down by one row makes Compose think every single item changed, forcing state and animations to reset even though the same data just moved.
Supplying a stable, unique key per item, usually the item's own id, lets Compose track identity across list changes instead of position. An item that moved keeps its remembered state, like an expanded state or animation progress, and items that didn't change at all aren't needlessly recomposed or torn down and recreated.
LazyColumn {
items(
items = todoList,
key = { item -> item.id } // stable identity across insert/remove/move
) { item ->
TodoItem(item)
}
}
A key isn't a stability annotation and it doesn't make an item immune to recomposition, it just makes sure Compose is comparing the right instance to the right instance in the first place.
Sometimes the unstable type isn't yours to fix, a model class shipped inside a third-party library, with no source access and no way to add @Immutable. Wrapping every call site in remember is a workaround, not a fix, and forking the dependency is overkill. The real answer is a **stability configuration file**: a plain text file listing fully-qualified class names, wildcards included, that you tell the Compose compiler to trust as stable regardless of what it can infer from the bytecode.
// compose_compiler_config.txt
com.thirdparty.sdk.ModelClass
com.thirdparty.sdk.*
composeCompiler {
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("compose_compiler_config.txt")
)
}
This is a promise you're making to the compiler, the same trust contract as @Immutable, just declared externally instead of in source, so only point it at types you've actually verified won't mutate in ways Compose needs to see.
Earlier in this lesson, relying on Strong Skipping Mode came up as a third fix for an unstable List parameter, without explaining what it actually does. Here it is.
**Strong Skipping Mode** is a Compose Compiler 2.0 change, default since Kotlin 2.0.20+, that relaxes the skippability requirement. Previously, a composable with even one unstable parameter became entirely non-skippable. Under Strong Skipping, it's **skippable regardless**, just with a different equality check per parameter:
- Stable parameters are compared with equals() (structural equality), same as before - Unstable parameters are compared with === (referential equality)
So passing a *new* List instance with identical content still triggers recomposition, since it's a different instance, but passing the *same* instance back, one that hasn't changed, now lets the composable skip, something that wasn't possible before. Strong Skipping also auto-wraps ordinary, non-composable lambdas in remember, keyed by their captures, so you get lambda stability for free without writing remember(x) { { ... } } yourself.
Some state changes far more often than the UI actually needs to recompose for, a drag offset ticking every pixel, for instance. **Deferred reads** avoid recomposition by reading that state inside a lambda that runs during layout or draw instead of composition:
// reads offsetX.value during composition, recomposes every frame
Modifier.offset(x = offsetX.value.dp)
// defers the read into the layout phase, no recomposition per frame
Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }
The lambda-based overloads of offset, graphicsLayer { }, and similar modifiers only read the state when Compose actually needs a new layout or draw pass, so a value changing every frame invalidates layout or draw only, and the composable's own body never re-runs because of it.
derivedStateOf narrows recomposition scope one step further: it wraps a computation over frequently-changing state and only notifies observers when the **computed result** actually changes, not on every underlying tick.
val isScrolled by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
firstVisibleItemIndex changes on essentially every scroll frame, but isScrolled only flips twice, false to true, and eventually back. Reading listState.firstVisibleItemIndex > 0 directly would recompose the observer on every scroll frame; wrapping it in derivedStateOf recomputes on every frame internally but only triggers recomposition on the rare frames where the boolean actually flips.
Collecting a Flow into Compose state has two options with a meaningful difference on Android. collectAsState keeps collecting for as long as the composition is alive, including while the app is backgrounded, since composition doesn't tear down just because the screen isn't visible.
collectAsStateWithLifecycle (from lifecycle-runtime-compose) ties collection to the LifecycleOwner, by default only collecting while it's at least STARTED, and cancelling upstream collection when it drops below that. That stops wasted work upstream, a network flow still emitting, a database observer still firing, while the screen is in the background, and resumes cleanly when the user returns.
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
On Android specifically, prefer collectAsStateWithLifecycle by default; reach for plain collectAsState mainly in non-Android multiplatform code with no lifecycle to tie to.
Move a composable to a different place in the tree, say a video player that needs to jump from a Column into a full-screen Box on rotation, and by default Compose treats that as the old instance being disposed and a brand new one composed in its place. Any remembered state, playback position, scroll offset, animation progress, is lost in that swap.
movableContentOf fixes this by wrapping a composable lambda so Compose can recognize it as the *same* content relocating, not new content appearing, preserving its remembered state and underlying nodes across the move.
val player = remember {
movableContentOf { VideoPlayer(url = videoUrl) }
}
if (isLandscape) {
FullScreenLayout { player() }
} else {
Column { player(); PlayerControls() }
}
The same player instance is reused in either branch, so switching orientation relocates it instead of restarting playback from zero.
Everything in this lesson describes what should make a composable skip, but you can't fix jank by intuition alone, you have to look. When a screen feels slow, look in this order.
Start with **Layout Inspector's recomposition counts**. They show you which composables are recomposing far more than the data changing would justify, pointing straight at the hotspot instead of guessing across an entire screen.
Once you've found the hotspot, turn on **Compose compiler metrics or reports** (build/compose_compiler/*-composables.txt and *-classes.txt). They tell you exactly which composables ended up skippable or restartable, and which of your classes the compiler inferred as unstable, so instead of guessing you can see precisely why a fix like ImmutableList or @Immutable would or wouldn't help.
Once you've made a change, confirm it with **Macrobenchmark and Baseline Profiles**, run on a **release, R8-minified build**, never debug. Debug builds carry extra runtime checks and skip R8's optimizations, so their timings run artificially slow and don't reflect what a real user on a real device experiences.
That three-step loop, Layout Inspector to find it, compiler reports to explain it, a release-build benchmark to confirm the fix, is what separates "I added remember everywhere" from an answer that holds up under a senior interviewer's follow-up questions. If you remember one thing from this whole topic, make it this: recomposition itself isn't the enemy, Compose is designed to recompose constantly and cheaply. The actual skill is knowing which parameter, which collection, which state read is quietly making something unskippable, and reaching for the fix, an annotation, an immutable collection, a deferred read, a lifecycle-aware collector, a stable key, that matches the actual cause.