Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Context
Jetpack Compose replaces Android's XML-based view system with a declarative UI framework. The runtime decides when and how to update the UI through a process called recomposition. Understanding this mechanism is essential for writing performant Compose code at scale.
Problem
Recomposition is Compose's way of re-executing composable functions when state changes. The problem: uncontrolled recomposition leads to frame drops, wasted CPU cycles, and subtle bugs. Unlike the View system where you explicitly call notifyDataSetChanged() or invalidate(), Compose decides on its own what to recompose. If you do not understand the rules, you lose control over performance.
Constraints
- Composable functions can be called in any order by the runtime
- Composable functions can execute in parallel (not guaranteed to run on main thread in the future)
- Recomposition can be skipped if the runtime determines inputs have not changed
- The slot table is the internal data structure that tracks composable state across recompositions
- Stability of parameters determines whether a composable is skippable
Design
The Slot Table
Compose maintains a data structure called the slot table (also referred to as the "gap buffer"). It stores:
- The current state of every composable in the tree
remembervaluesStateobjects- Group markers that define the composable hierarchy
When recomposition starts, the runtime walks the slot table, compares current parameters against stored parameters, and decides whether to re-execute each composable.
What Triggers Recomposition
Recomposition is triggered when a State object read during the previous composition changes. The runtime tracks which composables read which state objects through a snapshot system.
@Composable
fun UserCard(user: User) {
// This composable is subscribed to `user.name` and `user.avatarUrl`
// If either changes, this composable recomposes
Column {
Text(text = user.name)
AsyncImage(url = user.avatarUrl)
}
}The key mechanism: snapshot system. Compose wraps state reads in a snapshot that records which composable scope performed the read. When the state value changes, the runtime invalidates only those scopes.
Stability and Skipping
The compiler plugin annotates classes as @Stable or @Immutable based on their properties. A composable can be skipped (not re-executed) if all its parameters are stable and equal to their previous values.
| Condition | Skippable? |
|---|---|
All params are primitives or String | Yes |
All params are @Stable or @Immutable annotated | Yes |
Any param is an unstable class (e.g., List, Map, data class with var) | No |
Lambda param without remember | No, unless the lambda captures only stable values |
| No params at all | Yes (but still recomposes if internal state changes) |
// Unstable: List is a non-stable interface
data class ScreenState(
val items: List<Item>, // List is unstable
val title: String // String is stable
)
// Stable: use kotlinx.collections.immutable
@Immutable
data class ScreenState(
val items: ImmutableList<Item>,
val title: String
)Recomposition Scopes
The runtime does not recompose the entire tree. It recomposes at the granularity of restart groups, which roughly correspond to non-inline composable functions.
@Composable
fun ParentScreen(viewModel: MyViewModel) {
val count by viewModel.count.collectAsState()
val label by viewModel.label.collectAsState()
// Scope 1: recomposes when `count` changes
Counter(count = count)
// Scope 2: recomposes when `label` changes
Label(text = label)
}
@Composable
fun Counter(count: Int) {
Text("Count: $count")
}
@Composable
fun Label(text: String) {
Text(text)
}If count changes but label does not, only Counter recomposes. Label is skipped entirely. This granularity is why splitting UI into small composable functions is a performance strategy, not just a readability choice.
Deferred Reads and Lambda-Based Composition
One powerful pattern: defer state reads to the composition phase where they are actually needed.
// Bad: reads offset during composition, entire composable recomposes on scroll
@Composable
fun AnimatedBox(scrollOffset: State<Float>) {
val offset = scrollOffset.value
Box(modifier = Modifier.offset(y = offset.dp))
}
// Good: defers read to layout phase, skips recomposition entirely
@Composable
fun AnimatedBox(scrollOffset: State<Float>) {
Box(modifier = Modifier.offset { IntOffset(0, scrollOffset.value.toInt()) })
}The lambda-based offset runs during the layout phase, not the composition phase. The composable itself never recomposes.
Trade-offs
| Gain | Cost |
|---|---|
| Automatic UI updates from state changes | Implicit control flow makes debugging harder |
| Granular recomposition skips unnecessary work | Requires understanding stability rules |
| Declarative model simplifies UI code | Performance cliffs if stability is violated |
| Composition over inheritance | Inline functions cannot be recomposition scopes |
Failure Modes
See also: Debugging Performance Issues in Large Android Apps.
| Failure | Symptom | Fix |
|---|---|---|
| Unstable parameters on hot-path composables | Entire list recomposes on any state change | Use @Immutable data classes, ImmutableList |
| State read at wrong scope | Parent recomposes instead of child | Move state read into the narrowest composable |
| Lambda allocation on every recomposition | Child always recomposes because lambda identity changes | Use remember to stabilize lambdas, or use method references |
derivedStateOf misuse | Derived state recalculates unnecessarily | Ensure the derivation is cheaper than recomposition it prevents |
key() not used in dynamic lists | Slot table mismatches items, causes visual glitches | Use key(item.id) in forEach or items(key = { it.id }) in LazyColumn |
Scaling Considerations
Related: Event Tracking System Design for Android Applications.
- Large lists:
LazyColumnwith stable keys and stable item types. Without stability, scrolling performance degrades linearly with list size - Deep composition trees: avoid reading broadly-scoped state at the top. Push state reads down to leaf composables
- Shared state across screens: use
snapshotFlowto bridge Compose state to coroutines, avoiding unnecessary recomposition of intermediary composables - Module boundaries: classes from other modules are often treated as unstable unless annotated. Enable the Compose compiler stability configuration file to declare external stable types
// compose_compiler_config.conf
// Declare types from external modules as stable
com.example.models.User
com.example.models.Product
kotlinx.datetime.InstantObservability
- Compose Compiler Metrics: enable with
-P plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=path. Generates reports showing which composables are skippable, restartable, and which classes are stable - Layout Inspector: real-time recomposition counts per composable in Android Studio
- Recomposition Highlighter: a debug modifier that flashes composables when they recompose (useful during development)
- Perfetto traces: composition, layout, and draw phases appear as distinct slices. Look for
Compose:recomposemarkers
// Debug-only recomposition counter
@Composable
fun RecompositionTracker(label: String) {
if (BuildConfig.DEBUG) {
val count = remember { mutableIntStateOf(0) }
SideEffect { count.intValue++ }
Log.d("Recomposition", "$label: ${count.intValue}")
}
}Key Takeaways
- Recomposition is driven by the snapshot system. A composable recomposes only when a
Stateobject it read during the previous composition changes - Stability determines skippability. If a composable's parameters are all stable and unchanged, the runtime skips it entirely
- Scope matters. Read state in the narrowest possible composable to minimize the blast radius of recomposition
- Defer state reads to layout or draw phases when possible, using lambda-based modifiers
- Use the Compose compiler metrics to audit stability at build time, not as an afterthought
ImmutableListfrom kotlinx-collections-immutable is not optional for list-heavy UIs
Further Reading
- Avoiding Hidden Recomposition Traps in Compose: Practical catalog of non-obvious Compose patterns that trigger excessive recomposition, with fixes and measurement strategies for each.
- How Garbage Collection Impacts Android Performance: A detailed look at ART's garbage collection mechanisms, how GC pauses affect frame rates, and practical strategies to minimize GC impact ...
- Optimizing RecyclerView vs Compose Lists: A side-by-side comparison of RecyclerView and LazyColumn performance characteristics, optimization strategies, and trade-offs for product...
Final Thoughts
Recomposition is the core performance lever in Compose. The runtime is designed to skip work aggressively, but it can only do so when you give it the right signals through stable types, scoped state reads, and proper use of keys. Treat the Compose compiler metrics report as a first-class build artifact. Review it during code review. The cost of an unstable parameter on a frequently-recomposed composable compounds quickly in production.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.