Jetpack Compose Recomposition: A Deep Dive

Dhruval Dhameliya·February 15, 2026·7 min read

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
  • remember values
  • State objects
  • 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.

ConditionSkippable?
All params are primitives or StringYes
All params are @Stable or @Immutable annotatedYes
Any param is an unstable class (e.g., List, Map, data class with var)No
Lambda param without rememberNo, unless the lambda captures only stable values
No params at allYes (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

GainCost
Automatic UI updates from state changesImplicit control flow makes debugging harder
Granular recomposition skips unnecessary workRequires understanding stability rules
Declarative model simplifies UI codePerformance cliffs if stability is violated
Composition over inheritanceInline functions cannot be recomposition scopes

Failure Modes

See also: Debugging Performance Issues in Large Android Apps.

FailureSymptomFix
Unstable parameters on hot-path composablesEntire list recomposes on any state changeUse @Immutable data classes, ImmutableList
State read at wrong scopeParent recomposes instead of childMove state read into the narrowest composable
Lambda allocation on every recompositionChild always recomposes because lambda identity changesUse remember to stabilize lambdas, or use method references
derivedStateOf misuseDerived state recalculates unnecessarilyEnsure the derivation is cheaper than recomposition it prevents
key() not used in dynamic listsSlot table mismatches items, causes visual glitchesUse key(item.id) in forEach or items(key = { it.id }) in LazyColumn

Scaling Considerations

Related: Event Tracking System Design for Android Applications.

  • Large lists: LazyColumn with 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 snapshotFlow to 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.Instant

Observability

  • 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:recompose markers
// 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 State object 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
  • ImmutableList from kotlinx-collections-immutable is not optional for list-heavy UIs

Further Reading

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