DEV Community

Cover image for Inside the Compose Slot Table: Positional Memoization, Gap Buffers, and the Bugs They Cause
Android Expert
Android Expert

Posted on Originally published at Medium

Inside the Compose Slot Table: Positional Memoization, Gap Buffers, and the Bugs They Cause

Most Compose bugs I see in code review share one root cause: someone treated a @Composable function like a view factory instead of a function whose call position is its identity. Once you understand the slot table, state loss on reorder, remember that "doesn't remember", and mysterious skipping stop being mysterious.

This post covers what the slot table is, how recomposition walks it, and the failure modes you will hit in production.

What the slot table is

The slot table is Compose’s runtime record of your composition. Where the View system gives you a tree of heap objects, Compose gives you a linear, array-backed structure that is read and written in the same order your composable functions execute.

Take this code:

@Composable
fun Screen() {
    Column {
        Text("Hello")
        val counter = remember { mutableStateOf(0) }
        Text("World")
    }
}
Enter fullscreen mode Exit fullscreen mode

The mental model is a flat sequence of groups and data:

slot-table-linear-model

Nesting is encoded by position and group size, not by parent pointers you chase at runtime. That is what makes the walk cheap and cache-friendly.

The mental model is slightly wrong (on purpose)

The single-array picture is the right way to think, but the real SlotTable in androidx.compose.runtime splits the data across two parallel arrays:

  • groups: IntArray: a fixed number of ints per group (key, flags and node count, parent anchor, size, data anchor).
  • slots: Array<Any?>: the payload: remembered values, recompose scopes, previous values of non-constant parameters (for skipping checks), nodes, and so on.

One correction to the simplified picture above: "Hello" and "World" are compile-time constants. The compiler marks such arguments as static in $changed, so they are not stored in a slot. A parameter that varies at runtime, such as Text(name), is stored so the next recomposition can compare it. The linear diagram is a teaching model; group descriptors and slot payloads actually live in separate address spaces.

Both are gap buffers. Details change between runtime versions, so treat the field layout as illustrative rather than as an API.

slot-table-real-structure

Why gap buffers? Composition is mostly “edit near where I last edited”. A gap buffer keeps a hole at the last edit point, so insertions and removals there are close to free. A distant edit costs one array copy to move the gap.

gap-buffer-insert

Where the keys come from

You never write group keys. The Compose compiler plugin rewrites each composable to take an extra Composer and a $changed bitmask, and it wraps bodies in group calls with an Int key derived from the source location. Conceptually, Screen becomes:

// Conceptual output. Do not rely on exact shape.
fun Screen(composer: Composer, changed: Int) {
    composer.startRestartGroup(/* key = 0x1A2B... */)
    // body, with startReplaceGroup/startMovableGroup around
    // conditionals, loops, and key { } blocks
    composer.endRestartGroup()?.updateScope { c, _ -> Screen(c, changed or 1) }
}
Enter fullscreen mode Exit fullscreen mode

Three group flavors matter in practice:

  • Restart groups wrap composables that return Unit. They are the unit of recomposition scope.
  • Replaceable groups wrap branches (if/when). A different branch means a different key, so old slots are discarded.
  • Movable groups come from key(x) { }. They carry an object key, so the runtime can find and move a group instead of destroying it.

How recomposition walks the table

During recomposition the composer keeps a cursor into the table and does not rebuild the tree. At each group it asks:

recomposition-walk

In words:

  1. Same key, inputs unchanged: skip. The cursor jumps past the group using its recorded size, so none of its children run.
  2. Same key, inputs changed: re-run the body, reusing the slots already stored for children.
  3. Different key: look for a movable group with that key. If found, move it and keep its state. Otherwise, insert a new group, run the code, and remove the old slots.
  4. remember slot: compare the remember keys. Unchanged returns the cached value without running your lambda.

Skipping depends on the compiler comparing each parameter to the value stored in the slot table from the last composition. Stable types are compared with equals. Since Kotlin 2.0.20, strong skipping mode is on by default and also skips with unstable parameters, comparing them by instance (===). Check your compiler version before reasoning about why something did or did not skip.

Position is identity

Here is the sentence that explains most state bugs: the position in the slot table is the identity. Two remember {} calls at different positions get different slots, because they are different call sites in execution order.

That is also the failure mode.

Failure mode 1: Loops without keys

@Composable
fun ExpandableList(items: List<Item>) {
    Column {
        items.forEach { item ->
            // BUG: identity is the loop iteration order, not item.id
            var expanded by remember { mutableStateOf(false) }
            ItemRow(item, expanded) { expanded = !expanded }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The user expands B, then deletes A. Slot 0 still holds false, slot 1 still holds true:

positional-identity-bug

Fix it by giving each iteration a stable identity:

@Composable
fun ExpandableList(items: List<Item>) {
    Column {
        items.forEach { item ->
            key(item.id) {
                var expanded by remember { mutableStateOf(false) }
                ItemRow(item, expanded) { expanded = !expanded }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I kept plain remember so the only change is key(item.id). If the expanded state must also survive recreation or process death, swap in rememberSaveable. Its default key already incorporates the enclosing key(item.id) group, so no extra key is needed, but the value must be Bundle-saveable.

In lazy lists, the equivalent is items(items, key = { it.id }). Make sure keys are unique and stable. Duplicate keys crash LazyColumn at runtime, and key {} with duplicates silently produces ambiguous matching.

Failure mode 2: Keys that are not really keys

key(index) or key(item.hashCode()) on mutable data defeats the purpose. Index is just position again, and a hash that changes when the item mutates will tear down state on every edit. Use a stable domain ID.

Failure mode 3: Conditional branches silently reset state

@Composable
fun Profile(isEditing: Boolean, user: User) {
    if (isEditing) {
        var draft by remember { mutableStateOf(user.name) }
        EditForm(draft) { draft = it }
    } else {
        ReadOnlyForm(user)
    }
}
Enter fullscreen mode Exit fullscreen mode

Each branch has its own group, so leaving the isEditing branch discards draft. Often that is what you want; the bug is expecting it to survive. If state must outlive the branch, hoist it above the if, or store it in a ViewModel or rememberSaveable higher up.

Failure mode 4: remember with the wrong keys

@Composable
fun UserBadge(userId: String, repo: UserRepo) {
    // BUG: stale if userId changes; the slot already holds a value
    val user = remember { repo.cachedUser(userId) }

    // Correct: re-run when the input changes
    val fresh = remember(userId) { repo.cachedUser(userId) }
}
Enter fullscreen mode Exit fullscreen mode

Without keys, the lambda runs once for the group’s lifetime. Pass every input the lambda captures as a key.

Failure mode 5: Stale lambdas captured by long-lived effects

@Composable
fun Timer(onTimeout: () -> Unit) {
    // BUG: effect captures the first onTimeout forever
    LaunchedEffect(Unit) {
        delay(5_000)
        onTimeout()
    }
}

@Composable
fun TimerFixed(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {
        delay(5_000)
        currentOnTimeout()
    }
}
Enter fullscreen mode Exit fullscreen mode

rememberUpdatedState keeps one slot whose value is refreshed each recomposition, so the long-lived coroutine always reads the latest lambda.

Failure mode 6: Remembered objects leak what they capture

Slots hold strong references until their group leaves the composition. Remembering something that captures an Activity or a large Bitmap ties its lifetime to that group. For resources with cleanup, implement RememberObserver (or use DisposableEffect) so onForgotten runs when the slot is dropped.

// Common pattern: fine for normal lifecycles
@Composable
fun rememberPlayer(context: Context): ExoPlayer {
    val appContext = context.applicationContext
    val player = remember { ExoPlayer.Builder(appContext).build() }
    DisposableEffect(player) { onDispose { player.release() } }
    return player
}
Enter fullscreen mode Exit fullscreen mode

This has a gap. If composition is abandoned after remember runs but before the changes are applied (for example, a cancelled or interrupted composition), the DisposableEffect never enters, so onDispose never runs and the player leaks. Wrapping the resource in a RememberObserver covers forgotten and abandoned slots:

private class PlayerHolder(context: Context) : RememberObserver {
    val player: ExoPlayer = ExoPlayer.Builder(context).build()

    override fun onRemembered() = Unit
    override fun onForgotten() = player.release()
    override fun onAbandoned() = player.release()
}

@Composable
fun rememberPlayer(context: Context): ExoPlayer {
    val appContext = context.applicationContext
    return remember { PlayerHolder(appContext) }.player
}
Enter fullscreen mode Exit fullscreen mode

Failure mode 7: Expecting remember to survive recreation

The slot table belongs to the composition. When the Activity is recreated on a configuration change, the composition is rebuilt and every remember slot starts empty. Use rememberSaveable for small UI state, and a ViewModel for anything heavy or asynchronous.

Failure mode 8: Side effects during composition

Composition can run more than once, be abandoned, or run in a different order than you expect. Writing to state or doing I/O in the body is how you get infinite recomposition loops and flaky tests:

@Composable
fun Bad(vm: VM) {
    vm.track("screen_shown")   // runs on every recomposition
}

@Composable
fun Good(vm: VM) {
    LaunchedEffect(Unit) { vm.track("screen_shown") }
}
Enter fullscreen mode Exit fullscreen mode

Debugging checklist

  • Layout Inspector recomposition counts show which composables run and which are skipped.
  • Suspect state loss? Look for a branch, a loop, or a movableContentOf boundary above the remember.
  • Suspect missed skipping? Check parameter stability and the Compose compiler reports (-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...).
  • Reordering a collection? Every item needs a stable key.

Takeaways

  • The slot table stores the composition as groups and data in a linear, gap-buffered structure; the real implementation uses parallel arrays, and constants are not stored in slots.
  • Recomposition is a cursor walk: matching keys are skipped or re-run, mismatched keys move or replace.
  • Position is identity. remember is scoped to a call site in execution order, so keys, branches, and loops determine whether your state survives.
  • Most “Compose bugs” are identity bugs: add key(), hoist state, or key your remember.

📱 New to Jetpack Compose? Start Here

If you’re coming from the classic Android View system — XML layouts, findViewById, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend "Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI." Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — "Mastering Jetpack Compose Internals" is the natural next step.


Originally published on Medium.

Top comments (0)