Jetpack Compose Mechanisms, Part 3: Recomposition Performance and Why Stability Still Matters
Jetpack Compose Mechanisms, Part 3: Recomposition Performance and Why Stability Still Matters
Part 1 and
Part 2 followed a composable from the compiler, through the runtime, to pixels on the screen. That machinery is now fast enough that Google's own benchmarks put Compose level with Views for scrolling, and since strong skipping became the default, stability looks like something you no longer need to think about. Yet it is still easy to build a list where marking one message as read recomposes every visible row. Whether that happens depends less on the framework than on where your code creates new objects.
In this article, you'll dive deep into where Compose spends its time, starting from the three phases and working toward your own models: what a recomposition costs, how smart recomposition decides to skip, what strong skipping changed, and three everyday cases that create new instances, only some of which cost a recomposition.
This is the third and last article of a three part series based on my DroidKaigi 2026 & Droidcon Berlin 2026 talk, Jetpack Compose Mechanisms. You can follow along with the slides, and the diagrams in this series come from them.
The hero benchmarks: Compose against Views
Google tracks Compose performance with a set of hero benchmarks, run on a Pixel 3a with Android 12 and with the CPU and GPU clocks locked so that runs are comparable. They compare the
View based and
Compose based versions of an open source Pokedex app. The app is mine, and as the page notes, it was not developed by the Compose team.

In the scrolling benchmark, Compose and Views have had the same 0.21% jank rate since Compose 1.9.0, 1 janky frame out of 485. Startup still carries some overhead: Compose 1.11 is 2.5% slower than Views for time to initial display, and 13.0% slower for time to full display, both from a cold start. So for scrolling, the framework is no longer the bottleneck. What remains is the work your own code asks for, and understanding it starts with knowing when Compose does its work.
The three phases: Composition, layout, and drawing

Compose turns data into pixels in three phases:
- Composition decides what to show. The runtime executes composable functions and updates the composition.
- Layout decides where to place it. Compose UI measures each
LayoutNodeand positions it in the tree. - Drawing decides how it renders. Each node draws its content onto a
Canvas.
Composition belongs to the runtime from Part 2, and layout and drawing belong to Compose UI. A change does not always need all three phases. A state read only in a later phase skips the earlier ones: a read during drawing reruns only drawing, and a read during layout skips composition. This is why Modifier.offset { }, which reads its value during layout, is the cheaper choice for an animated offset than Modifier.offset(x, y), which reads it during composition. The Three Phases: Composition, Layout, and Drawing covers how each phase is invalidated. This article stays with composition, because that is where your composable functions run.
Recomposition cost: More than calling your function
Zoom into the composition phase, and calling your function turns out to be one step among several. Using the pieces from Part 2, in order:
- The runtime finds the recompose scopes that were invalidated.
- It walks the existing composition in the SlotTable to each of those scopes.
- It runs each of those scopes again, recording the changes it needs in a change list.
- The
Applierapplies those changes to theLayoutNodetree.

The first composition runs every scope, writes the table, and builds the tree. A recomposition starts only from the invalidated scopes, reuses most of the table, and often has nothing to change in the tree. It is still not free. Each scope that runs executes its body and walks its part of the table, and every composable it calls compares its arguments before it can skip. A change that reaches the tree can trigger layout and drawing as well, so the work worth removing is the recompositions that cannot change anything.
Smart recomposition: Skipping what did not change

You already know the mechanism from Part 1. When a composable receives the same inputs as last time, idempotence says it would produce the same result, so the generated code skips its body. This is what is usually called smart recomposition.

In a tree, that decision repeats at every level. When state in ChatScreen changes, ChatScreen runs again and calls its children. ChatHeader and InputBar receive the same inputs as before, so they skip, and their own children are never visited. MessageList receives a changed input and runs, and inside it one MessageBubble skips while the other recomposes. Skipping at the top of a subtree saves everything below it, which makes the comparison itself worth understanding: everything depends on whether Compose can tell that an input is the same.
Stability: Before and after strong skipping
Whether an input counts as the same depends on its type. A type is stable when Compose can trust its equals() and is notified whenever its public state changes: primitives, String, and classes whose properties are all vals of stable types, for example. A plain var property, one not backed by snapshot state, makes a class unstable, because it can be reassigned without Compose finding out. Interfaces such as List are a harder case: List is only a read only view, and the object behind it can be a MutableList that other code changes. Up to Kotlin 2.3, the compiler treated interfaces from other modules, List included, as unstable, and Kotlin 2.4 changed that, as the compiler reports below show.
Before Kotlin 2.0.20, stability decided skipping outright:
- Any unstable parameter that the body used and that had no default value made the composable impossible to skip. No comparison was generated, and the function ran every time its caller did.
- All stable parameters were compared with
equals(), and the function skipped when nothing changed.
Strong skipping mode, on by default since Kotlin 2.0.20, changed the first rule:
- Unstable parameters are compared by reference, with
===, throughchangedInstance. - Stable parameters are still compared with
equals(), throughchanged.
Two kinds of parameters sit outside these rules. Function types such as lambdas are always compared by reference. For parameter types whose stability is only known at runtime, such as classes compiled with the Compose compiler in another module and, since Kotlin 2.4, classes declared in another file and interfaces such as List, the generated code picks between the two comparisons at runtime. The caller passes a bit for each argument, read from a field the compiler generates on each public or internal class it compiles: by reference when that field says unstable, and with equals() otherwise. An interface has no such field, and neither does a class the Compose compiler did not compile, such as ArrayList, so arguments for a List parameter are compared with equals(). Strong skipping also remembers lambdas that capture unstable values, which used to be recreated on every run, so a lambda whose captures are the same instances arrives as the same instance.
I ran this in two small Robolectric tests on the JVM (Kotlin 2.3.21, Compose 1.11.2). In each, a parent recomposed 6 times after its first composition, passing the same unstable object to a child in one test, and a lambda capturing that object in the other. With strong skipping turned off, the child ran 7 times in both tests, once for every run of the parent. With it on, the child ran once, because the same instance arrived every time.

Compiler reports: What the compiler decided
So does stability still matter? In most cases, strong skipping is enough. But it does not change the stability of any type; only the way the generated code compares unstable values changes. The Compose compiler reports show what the compiler decided. Turn them on in a module's build file:
composeCompiler {
reportsDestination.set(layout.buildDirectory.dir("compose_compiler"))
}
Reports are written while the Kotlin compile task runs, so an up to date task writes nothing; compare reports from full compiles by running a clean build or forcing the compile task to rerun. Then compile a composable whose last parameter is a List. The body has to use badges, because the report marks a parameter the body never reads as unused, and an unused parameter never blocks skipping:
@Composable
fun Avatar(
modifier: Modifier = Modifier,
imageUrl: String? = null,
initials: String? = null,
shape: Shape = CircleShape,
badges: List<String>,
) {
Box(modifier.clip(shape)) { badges.forEach { Text(it) } }
}
Each entry in the report starts with what the compiler decided about the function, followed by one line per parameter. restartable means the function got the restart group from Part 1, and skippable means the generated code may skip it. Each parameter is marked stable or unstable, or carries no marker when its stability is only known at runtime. scheme(...) names the kind of tree the composable emits into, and @static and @dynamic describe its default values; you can ignore both here. With Kotlin 2.3 and strong skipping turned off, the *-composables.txt report describes Avatar like this, with the package name dropped:
restartable scheme("[androidx.compose.ui.UiComposable]") fun Avatar(
stable modifier: Modifier? = @static <expression>
unused stable imageUrl: String? = @static null
unused stable initials: String? = @static null
stable shape: Shape? = @dynamic <expression>
unstable badges: List<String>
)
With strong skipping on, Kotlin 2.3 changes only the first line: skippable appears after restartable, while unstable badges stays exactly as it is. The function can now be skipped, but only when the same List instance arrives again. That was the talk's point: strong skipping changes how unstable values are compared, not whether they are unstable.
Kotlin 2.4 changed the verdict for interfaces. The compiler now treats every interface that is not marked or known as stable, List included, as unknown until runtime, and the report prints the last line as badges: List<String> with no marker. For such a parameter, the generated code takes the comparison from the bit the caller passes, and an interface has no field to fill it from, so a List built by map is compared with equals(), element by element.
Stability still matters when new instances of a type that is compared by reference reach a composable. The next three cases move the source of those instances further away each time: the composable's own body, a ViewModel, and a repository. The first case and the fix for the third also show where Kotlin 2.4 changes the outcome.
Case 1: A new list on every recomposition
The first source is the composable's own body, mapping a list on every run:
@Composable
fun UserScreen(users: List<User>) {
UserList(
users = users.map { user ->
UserProfile(name = user.name, avatarUrl = user.avatarUrl)
},
)
}
Every time UserScreen recomposes, for any reason, map() creates a brand new List. Up to Kotlin 2.3, List is unstable, so strong skipping compares it by reference: a new instance means a different reference, and UserList runs again even though nothing inside the list changed. The fix is to keep the instance:
@Composable
fun UserScreen(users: List<User>) {
val profiles = remember(users) {
users.map { user -> UserProfile(name = user.name, avatarUrl = user.avatarUrl) }
}
UserList(users = profiles)
}
remember(users) is the keyed remember from Part 2: it keeps the mapped list until users no longer equals the key stored last time. When UserScreen recomposes with the same users, UserList receives the same instance, and the reference comparison succeeds. When I forced UserScreen to recompose 5 times in the same test setup on Kotlin 2.3.21, UserList ran 6 times with the inline map and only 1 time with remember(users). The type did not change. Keeping the same instance was enough.
On Kotlin 2.4.20, the inline map version ran UserList only 1 time as well. The new list is compared with equals(), and because UserProfile is a data class, two lists with the same contents are equal. The remember is still worth keeping: without it, every recomposition rebuilds the list and compares it element by element, and if the elements do not override equals(), two lists of fresh elements are never equal, so UserList runs again on any version.
The fix also assumes that users is replaced with a new list whenever the data changes, as it is when the list comes from a StateFlow. A snapshot state list from mutableStateListOf is mutated in place instead. It is the same object before and after a change, so it still equals the stored key, and the mapping is never recomputed. For that kind of list, read it inside derivedStateOf, so that the mapping follows the list's contents:
val profiles by remember(users) {
derivedStateOf {
users.map { user -> UserProfile(name = user.name, avatarUrl = user.avatarUrl) }
}
}
Case 2: New instances that never reach Compose
The second source is a ViewModel. In an MVI style ViewModel, every update copies the state and rebuilds its list:
data class ItemUi(val id: Int, val title: String)
data class UiState(val items: List<ItemUi> = emptyList())
class DemoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun updateItems() {
_uiState.update { state ->
state.copy(items = state.items.map { it.copy() })
}
}
}
The screen collects the state and passes the list down:
@Composable
fun DemoScreen(viewModel: DemoViewModel) {
val uiState by viewModel.uiState.collectAsState()
ItemList(items = uiState.items)
}
Every call to updateItems() creates a new UiState, a new list, and new items, so it looks like a steady stream of new unstable objects heading for ItemList. But there is one more step before anything reaches Compose: StateFlow ignores a new value that equals the one it already holds, the same way distinctUntilChanged() does. Looking at StateFlowImpl.updateState in kotlinx.coroutines, the first check serves compareAndSet, and the second is the one that matters here:
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
_state.value = newState
UiState and ItemUi are data classes, so a copy with the same contents is equal to the original, and the update stops at oldState == newState: the old value stays in place and nothing is emitted. In the test setup, calling updateItems() 5 times recomposed neither DemoScreen nor ItemList, and the flow still held the original instance. A new instance does not always mean a new recomposition, so look at the whole pipeline, not only the composables at the end of it.
Case 3: An unstable model in a LazyColumn
The third source sits furthest away, in a repository, and this time the new instances do get through. The repository maps database entities into a Message model on every emission, and the ViewModel turns the flow into a StateFlow:
class MessageRepository(private val dao: MessageDao) {
fun observeMessages(): Flow<List<Message>> =
dao.observeMessages()
.map { entities -> entities.map { it.toMessage() } }
}
class MessageViewModel(repository: MessageRepository) : ViewModel() {
val messages = repository.observeMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
}
Message is a data class with a mutable property and a plain list, which makes it unstable:
data class Message(
val id: String,
var isRead: Boolean,
val reactions: List<String>,
)
The screen renders the messages in a lazy list:
@Composable
fun MessageScreen(viewModel: MessageViewModel) {
val messages by viewModel.messages.collectAsState()
LazyColumn {
items(items = messages, key = { it.id }) { message ->
MessageBubble(message = message)
}
}
}
Walk through what happens when one message is marked as read:
- Room emits: a Room query flow emits again whenever its table is written, and the repository maps every row into a new
Message. - Equality check: if no row had changed, the new list would equal the old one, because
Messageis a data class, and theStateFlowbehindstateInwould drop it, as in Case 2. One row did change, so the list is emitted. - New instances: every element of the emitted list is a new
Message, including the ones that did not change. - Comparison: the
keyletsLazyColumnmatch each item to its previous composition, but eachMessageBubblestill receives a new object.Messageis unstable, so strong skipping compares by reference, and every comparison fails.
In the test setup, which feeds a flow that emits the way Room does through the same map and stateIn steps, marking one of 12 visible messages as read recomposed all 12 bubbles, on Kotlin 2.3.21 and 2.4.20 alike. The var alone keeps Message unstable on every version.
Fixing Case 3: Stability at the model
The problem is the model, so the fix belongs there too. Make the mutable property read only, and replace the plain list with an immutable collection:
data class Message(
val id: String,
val isRead: Boolean,
val reactions: ImmutableList<String>,
)
ImmutableList<T> from kotlinx.collections.immutable is one of the types the compiler knows to be stable, as long as T is stable, so Message is now inferred stable and compared with equals(). The unchanged messages compare equal even though they are new instances, so their bubbles skip. With this model, marking one message as read recomposed 1 of the 12 bubbles.
On Kotlin 2.4, where List is no longer unstable, it is tempting to stop at the read only property, and whether that works depends on where Message is declared. With isRead as a val and the plain List kept, Kotlin 2.4.20 recomposed 1 of the 12 bubbles when Message sat in the same file as the composables, and all 12 when it moved into a file of its own, as models usually do. Since Kotlin 2.4, an unannotated class like this Message, declared in another file, is checked at runtime through the field the compiler generates on it, and for this model that field says unstable, so the bubbles compare by reference again. On Kotlin 2.3.21, this model recomposed all 12 in both layouts. With ImmutableList, 1 of the 12 recomposed on both versions and in both layouts. With the model in its own file, the composables report on Kotlin 2.4.20 prints the message parameter with no marker for all three versions of Message, so count recompositions to tell them apart.
One condition is easy to miss: the compiler only infers stability for classes that the Compose compiler itself compiled. If Message lives in a data module that does not apply the Compose compiler, which is common, it stays unstable whatever its fields are, and the two options below are the ones that work.
If you would rather keep the plain List, you can declare the class stable instead:
@Immutable
data class Message(
val id: String,
val isRead: Boolean,
val reactions: List<String>,
)
The compiler does not check @Immutable, and its documentation says so: "The immutability of the class is not validated and is a promise by the type that all publicly accessible properties and fields will not change after the instance is constructed." Keep the promise, and the result is the same: 1 of the 12 bubbles recomposed. Break it, and Compose may skip a composable that should have updated.
Stability for a whole package: The configuration file
@Immutable works even in a module that does not run the Compose compiler, as long as that module can depend on the Compose runtime annotations. Annotating still works class by class, though. For a whole layer, for classes you do not own, or for a module that should not depend on Compose at all, there is a stability configuration file. Now in Android uses one for its entire data model package:
// We always use immutable classes for our data model, to avoid running the Compose compiler
// in the module we declare it to be stable here.
com.google.samples.apps.nowinandroid.core.model.data.*
As the comment says, the model module is a plain Kotlin library that does not run the Compose compiler, so nothing would infer stability for those classes. The file marks every matching class as stable, which for classes without type parameters is the same verdict an annotation would give, applied to a whole package in one place. A single * matches one name segment, so data.* covers the classes directly in that package, while ** would also reach subpackages. The build logic adds the file to every Compose module through the Compose compiler extension, the same extension the composeCompiler { } block configures:
stabilityConfigurationFiles
.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
Outside a build that uses Gradle's isolated projects, rootProject.layout.projectDirectory.file("compose_compiler_config.conf") does the same. stabilityConfigurationFiles is available from Kotlin 2.1.0; Kotlin 2.0.0 through 2.0.21 take a single file through stabilityConfigurationFile. Optimize App Performance by Mastering Stability in Jetpack Compose goes further into the patterns that break stability and how to fix each one.
Seeing stability: Compose Stability Analyzer

Compose Stability Analyzer, an Android Studio and IntelliJ plugin I maintain, shows the stability of each composable and its parameters right in the editor while you write, so you can see what is stable, what is unstable, what is only decided at runtime, and what affects skipping. For runtime numbers, the project's Gradle plugin adds
@TraceRecomposition: annotate a composable and run the app, and its recompositions are logged to Logcat, which the IDE plugin's heatmap can overlay as live counts above each function. Android Studio's Layout Inspector shows recomposition counts as well; the analyzer adds the stability information next to your code. Compose Stability Analyzer: How Compose Decides Your Class Is Unstable, and What It Costs You explains how it reaches its verdicts.
Conclusion
In practice, the work is less about chasing stability everywhere and more about knowing where new instances come from. Strong skipping already covers the common case, where the same objects flow down unchanged. The waste shows up where code recreates objects on every pass and hands them to a composable that compares by reference. When a composable recomposes more often than its data changes, follow its arguments back to the place that created them and fix the problem there. Measure before and after with the compiler reports and a recomposition counter.
What ties the three parts together is that Compose does as little work as it can prove is safe, and the proof is always a comparison. Part 1 found that comparison in the code generated for every composable, and this part found it upstream as well, in a StateFlow and in a remember key, which is why the fix often belongs somewhere other than the composable that recomposes.
If you want to go deeper than this series can, my book Jetpack Compose Mechanisms walks through the compiler, the runtime, and Compose UI in much more detail, reading each one from the actual source the same way this series does.
As always, happy coding!
— Jaewoong (skydoves)

