Recomposition is not re-rendering, and that is why your list stutters
Compose re-executes functions whose inputs changed. An unstable parameter means all of them.
TL;DR
"Recomposition" is not drawing. It is Compose re-executing a composable function to find out what it would emit now. Drawing happens afterwards, and only for what actually changed.
That distinction matters because recomposition is skippable. Compose compares each parameter to
its previous value and skips a function whose inputs are unchanged — but only if it can prove
the parameter is stable. A List<Item> parameter cannot be proven stable, so the check is
abandoned and the function re-runs every time its parent does.
One unstable parameter on one row composable turns a 60-item list into 60 re-executions per keystroke. The compiler metrics report names every unstable class you own.
The case this post is about
A list with a search field above it, which is the ordinary shape:
@Composable
fun SearchScreen(vm: SearchViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
Column {
SearchField(state.query, onQueryChange = vm::onQueryChange)
LazyColumn {
items(state.results, key = { it.id }) { item ->
ResultRow(item = item, tags = item.tags, onClick = vm::onClick)
}
}
}
}
@Composable
fun ResultRow(item: Item, tags: List<String>, onClick: (String) -> Unit) {
Row {
Text(item.title)
tags.forEach { Chip(it) } // a few composables each
Text(formatRelative(item.updatedAt))
}
}
Nothing unusual. Keys on the items, state hoisted to a ViewModel, a stable-looking data class.
And typing in the search field drops frames.
The symptom
Three measurements.
Frame timing degrading with list length:
type one character, 60 visible+cached items
frame duration P50 7.8ms
frame duration P95 61.2ms
jank frames 9.4%
Recomposition counts from the Layout Inspector, which is where the shape of the problem shows:
composable recompositions skips
SearchScreen 12 0
SearchField 12 0
ResultRow 720 0 ← 60 items × 12 keystrokes
Chip 2,160 0
skips: 0 on ResultRow is the finding. Compose was not even trying to skip it — every keystroke
re-executed every visible row and every chip inside them, despite none of those items changing.
And the compiler metrics report, which names the cause directly:
unstable class Item {
stable val id: String
unstable val tags: List<String>
<runtime stability> = Unstable
}
restartable scheme("[androidx.compose.ui.UiComposable]") fun ResultRow(
unstable item: Item
unstable tags: List<String>
stable onClick: Function1<String, Unit>
)
Why the obvious fix failed
"Add key to the items." Already there, and key solves a different problem — item identity
across reorders. It does not affect whether a row's body is skippable.
"Wrap the row in remember." remember caches a value, not an execution. A composable's
emission is not something you can memoise by wrapping it.
"Make ResultRow private / inline it." Changes nothing about parameter stability, and
inlining removes the restart scope entirely — which makes it worse, because now the parent
re-executes the row's content with no skip boundary at all.
"Switch List to Array." Array is also unstable (it is mutable). This is the fix that looks
like a type-level fix and is not.
"Hoist the query into a local remember so the ViewModel state does not change." My first real
attempt, and it worked by accident — it stopped SearchScreen recomposing on keystrokes, which hid
the row problem until the results changed instead. Fixing the trigger rather than the
skippability just moves the stutter.
The actual mechanism
Compose has two separate ideas that both get called "recomposition" in conversation.
Re-execution. Compose calls your composable function again, builds the new emission, and compares it to the slot table. This is cheap per call and not free, and it is what the recomposition counts measure.
Drawing. Only nodes whose measured/drawn output actually differs get re-laid-out and re-drawn. Compose is already good at this; it is rarely the problem.
The lever is skipping, which happens at re-execution time:
At each restartable composable, Compose compares every parameter to its previous value. If all
are == and all are stable, it skips the call entirely — the body never runs.
And there is a second lever that is not skipping at all, which matters most for the cases skipping cannot reach. Compose runs three phases — composition, layout, draw — and a state read only invalidates the phase it happens in. So where you read a value decides how much work its change causes:
// reads offset during COMPOSITION — every change re-executes this composable
Box(Modifier.offset(x = scrollOffset.dp))
// reads offset during LAYOUT — composition is skipped entirely
Box(Modifier.offset { IntOffset(scrollOffset.roundToInt(), 0) })
// reads alpha during DRAW — composition and layout both skipped
Box(Modifier.graphicsLayer { alpha = fadeProgress })
The lambda-taking overloads exist for exactly this. They are not a style variant — deferring the read moves the invalidation one or two phases later, and for a value changing every frame that is the difference between re-executing a subtree sixty times a second and re-drawing it.
Which is why a composable can be perfectly skippable and still expensive: skipping compares
parameters, and a state object read inside the body is not a parameter. Those reads subscribe
the composable regardless of how stable its signature is, and the fix is not another @Immutable
annotation — it is reading the value in the phase that actually needs it.
Stability is a compile-time proof, not a runtime check. A type is stable if the compiler can
prove that (a) its public properties will not change without notifying composition, and (b)
equals is consistent with that. data class Item(val tags: List<String>) fails: List is an
interface whose implementation might be a MutableList, so the compiler cannot prove the contents
will not change under it.
An unstable parameter disables the skip for the whole function. Not partially — the comparison is abandoned and the function is treated as always-changed.

Skipping is Compose's main performance mechanism and it requires a proof. An unstable parameter is not a slow path — it is the absence of the check, so the cost scales with how many times the parent recomposes.
Which explains the numbers: 12 keystrokes × 60 rows = 720, and skips: 0 because the proof was
never available.
The fix
Make the type provably immutable, which is a change to the data model rather than to the UI.
// the model — ImmutableList carries the proof the compiler needs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
data class Item(
val id: String,
val title: String,
val updatedAt: Instant,
val tags: ImmutableList<String>, // was List<String>
)
// build.gradle.kts
implementation(libs.kotlinx.collections.immutable)
// the mapper — convert once, at the boundary
fun ItemDto.toItem() = Item(
id = id,
title = title,
updatedAt = Instant.parse(updated),
tags = tags.toImmutableList(),
)
Three things worth being precise about:
ImmutableList is stable because the compiler is told so, via the library's
@Immutable-marked interfaces. It is a declaration that the contents will not change, backed by
a type that enforces it.
The conversion happens once, in the mapper. Calling .toImmutableList() inside a composable
would allocate a new list on every call, which creates a new != value and defeats skipping again
— a trap that looks like the fix.
The state holder needs the same treatment, or the screen's own parameter is unstable:
data class SearchState(
val query: String,
val results: ImmutableList<Item>, // not List<Item>
)
And two alternatives that are right in different situations:
@Immutable on your own class, when you control it and can honour the contract:
@Immutable // a PROMISE: all properties are immutable
data class Palette(val colors: List<Color>)
This is an assertion the compiler trusts without proving. It is correct here only if nothing ever mutates that list — breaking the promise produces a UI that silently fails to update, which is a nastier bug than the one you are fixing.
A StableConfig file for types you do not own:
# compose_compiler_config.conf
com.example.thirdparty.ReadOnlyModel
Same trust model, same caveat.
Strong skipping mode (default in recent Compose compiler versions) changes this picture usefully: it allows skipping for unstable parameters compared by instance equality, which rescues many cases automatically. It is a reason to be on a current compiler — and not a reason to leave your models unstable, because instance equality only helps when you are passing the same instance.
How we proved it
That the rows were not skipping, and now do. Layout Inspector counts, same 12 keystrokes:
composable before (recompositions / skips) after
SearchScreen 12 / 0 12 / 0
SearchField 12 / 0 12 / 0
ResultRow 720 / 0 0 / 720
Chip 2,160 / 0 0 / 2,160
0 / 720 — re-executed zero times, skipped 720 times — is the whole fix in one row. The rows are
now proven unchanged and their bodies never run.
That the frame timing followed:
type one character, 60 items, Pixel 6a
before after
frame duration P50 7.8ms 6.4ms
frame duration P95 61.2ms 8.1ms
jank frames 9.4% 0.2%
And that the compiler agrees, which is the check that prevents regression:
$ ./gradlew assembleRelease -Pcompose.metrics=true
$ grep -A3 'class Item' build/compose-metrics/*-classes.txt
before: unstable class Item { unstable val tags: List<String> }
after: stable class Item { stable val tags: ImmutableList<String> }
Worth wiring into CI as a guard, because an innocent-looking model change undoes it silently:
! grep -q 'unstable class \(Item\|SearchState\)' build/compose-metrics/*-classes.txt \
|| { echo "❌ a UI model became unstable"; exit 1; }
One limit, stated plainly: these numbers are from one device, one list, and one Compose compiler version — and with strong skipping enabled, an unstable parameter passed as the same instance may already skip, so a project on a current compiler will see a smaller delta than this. The durable part is the mechanism and the metrics report, not the 61ms.
What this generalizes to
Know which word your framework means. "Re-render", "recompose", "invalidate", "dirty" are different operations with different costs in different frameworks. Performance work starts with learning which one your measurements are counting.
An optimisation that needs a proof will silently not apply. Skipping, memoisation, constant folding, escape analysis — each is conditional, and the conditions are usually invisible at the call site. Find the tool that reports whether the optimisation fired; a counter of 0 skips is worth more than any amount of reasoning.
Immutability is a performance feature, not only a correctness one. The compiler can only skip what it can prove will not change. Making your data model honestly immutable is what unlocks the framework's own machinery.
Fix the skippability, not the trigger. Stopping the parent from recomposing hid the problem until a different trigger appeared. When something is unnecessarily expensive, make it cheap rather than making it rare.
Related
- Your data class is unstable and Compose told you by recomposing everything — the stability contract in detail
- derivedStateOf exists because you were recomposing on every keystroke — narrowing what invalidates
- A LazyColumn without keys loses your scroll position and your animations — what keys actually do
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".