Skip to main content

Compose Internals

At this point you can build anything in Compose. This chapter explains how the magic works under the hood — the slot table, recomposition scopes, stability inference, and what the Compose compiler actually does to your @Composable functions. Understanding this separates users from toolmakers.

The three phases

Compose render flow
1ReactJSX & state2Shadow TreeImmutable C++3CommitPrioritize updates4MountApply to views5Render60–120fps frames
Every frame: compose, measure+layout, draw. Each phase can invalidate the next.
┌──────────────────────────────────────────────────────────────────┐
│ 1. COMPOSITION │
│ Execute @Composable functions │
│ Build / update the node tree (LayoutNodes + their state) │
│ │
│ 2. MEASURE + LAYOUT │
│ Each LayoutNode asks its children to measure │
│ Positions are assigned │
│ │
│ 3. DRAW │
│ Each LayoutNode issues draw commands to a RenderNode │
│ RenderNodes are composited by the system │
└──────────────────────────────────────────────────────────────────┘

State changes can invalidate any phase independently:

  • Change state read inside a Composable → recompose only that scope
  • Change a layout input (size, offset) → skip composition, redo layout + draw
  • Change a draw input (color, alpha via graphicsLayer) → skip composition + layout, redo draw only

This is why graphicsLayer { alpha = x } for fades is so cheap: it skips two phases.


The slot table

Compose stores the composition state in a slot table — a gap buffer that holds, in order, every value, state, remember, group, and node you produce. Think of it as a flat array with a cursor.

Slot table (flat, ordered):
┌────────────────────────────────────────────────────────────────┐
│ group(App) │ group(Column) │ group(Greeting) │ remember(name) │
│ node(Text) │ group(Text) │ value("Aarav") │ ... │
└────────────────────────────────────────────────────────────────┘
↑ cursor during composition moves through the buffer

Each @Composable call site emits a group (a logical container). The compiler inserts a key automatically, based on the call site's source file and line number — so two calls to Greeting("A") at different lines get different keys and are independent.

Positional memoization

The slot table is indexed by position in the call tree, not by input.

@Composable
fun Screen() {
var text by remember { mutableStateOf("") } // slot at position [0]

if (showName) {
Text("Hello $text") // group at position [1]
}

TextField(value = text, onValueChange = { text = it }) // group at position [2] (or [1] if showName=false)
}

Because TextField's slot depends on whether showName is true, toggling showName moves the TextField's slot — and loses its state (focus, scroll, etc.).

The fix — explicit keys

if (showName) {
Text("Hello $text")
}

key("text-field") { // explicit key pins the slot
TextField(value = text, onValueChange = { text = it })
}

key { } forces a stable slot identity. LazyColumn's key = { it.id } does the same thing for list items.


Composition vs recomposition

Composition — the first time a composable runs, it creates groups, calls remember, emits nodes. The slot table is built.

Recomposition — a state read inside a composable changes. Compose invalidates the scope containing that read, then re-runs only that scope. The slot table cursor advances to the group and re-executes from there, reusing existing remember values.

@Composable
fun Parent() {
var count by remember { mutableIntStateOf(0) }

Column {
Header() // no state read — skipped
Counter(count) // reads count — recomposes
Footer() // no state read — skipped
}

Button(onClick = { count++ }) { Text("Inc") }
}

When count changes, only Counter(count) recomposes. Header and Footer are marked skippable and retain their state.


Recomposition scopes

A recomposition scope is the smallest unit Compose can invalidate and re-run independently. Every @Composable function that returns Unit can become a scope — but only if it's restartable.

Restartable — the compiler-inserted wrapper

The Compose compiler wraps most composables in a restart group:

// Your code
@Composable
fun Greeting(name: String) {
Text("Hello, $name")
}

// After compilation (simplified)
@Composable
fun Greeting(name: String, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(key)
// ...
Text("Hello, $name", ...)
// ...
$composer.endRestartGroup()?.updateScope { $newComposer, $newChanged ->
Greeting(name, $newComposer, $newChanged or 1) // restart point
}
}

When a state read inside the scope changes, Compose calls updateScope — restarting only this function.

When is a composable NOT restartable?

  • Returns a value (e.g. remember { computeSomething() } is a direct call, not a scope). Non-Unit-returning composables are inlined.
  • Has an @ReadOnlyComposable or @NonRestartableComposable annotation (rare, used by the framework).
  • Is @Composable inline fun — inlined at call site.

Check the Compose compiler metrics report (Module 10) for a restartable skippable scheme(...) annotation on every composable that can be skipped on recomposition.


Skippability — the stability contract

A restartable composable is skippable if all its parameters are stable and equal to the previous call.

What stability means

A type T is stable if:

  1. Its equals() is consistent — same inputs always produce same output.
  2. When T mutates, Compose is notified (i.e., mutation happens via State<T>).

The Compose compiler infers stability for:

  • Primitives (Int, Boolean, ...)
  • Function types (() -> Unit)
  • Types annotated @Stable or @Immutable
  • data class whose properties are all stable (Kotlin 2.0+ inference)

Not stable by default:

  • List, Map, Set — interfaces; the runtime can't prove immutability
  • MutableList, arrays
  • Classes from modules without the Compose compiler plugin
  • Any class with a var property unless marked @Stable

Fixing instability

// ❌ Unstable — List is an interface
data class Inbox(val messages: List<Message>)

// ✅ Stable — annotate as immutable
@Immutable
data class Inbox(val messages: List<Message>)

// ✅✅ Better — use kotlinx.collections.immutable
@Immutable
data class Inbox(val messages: ImmutableList<Message>)

@Immutable is a promise: every property is immutable for the lifetime of the instance. Violating it corrupts Compose's state.

@Stable is weaker: the object may mutate, but it will notify Compose (e.g., via MutableState).


Strong skipping mode (Compose 1.7+)

Kotlin 2.0 + Compose Compiler 1.7 introduced strong skipping: even without @Stable/@Immutable, restartable composables skip when their parameters are reference-equal to the previous call.

// Before strong skipping: this was non-skippable if List is unstable
@Composable
fun MessageList(messages: List<Message>) { /* ... */ }

// With strong skipping: skips when `messages` is the same reference

Strong skipping is on by default in Compose 1.7+. It reduces the stability-chasing tax, but you still benefit from @Immutable + ImmutableList because .copy() of a state holder produces a new reference — a stable class's members compared by .equals() would still skip.


Snapshots — how state-tracking works

Compose uses a snapshot system (inspired by software transactional memory) to track state reads and writes.

// Every read of a State<T> inside a composable registers the scope
// with that State. When the State's value changes, every registered
// scope is invalidated.

val count = mutableStateOf(0)

// Inside a composable:
Text("$count") // this scope is now a reader of `count`

// Later:
count.value = 1 // every reader scope invalidates

The snapshot system extends to nested reads — if your scope reads a derivedStateOf, which reads count, your scope is indirectly subscribed to count through the derivedStateOf's caching layer.

snapshotFlow — bridge to coroutines

LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { analytics.logScroll(it) }
}

snapshotFlow re-executes the lambda whenever any State read inside changes, then emits. This is the canonical bridge from Compose to Flow.


What the Compose compiler does

Adding parameters

Every @Composable function gets $composer: Composer and $changed: Int parameters appended:

// Your code
@Composable fun Greeting(name: String) { Text("Hi $name") }

// Transformed signature
fun Greeting(name: String, $composer: Composer, $changed: Int) { ... }

Stability parameter bits

Each parameter contributes bits to $changed indicating whether it changed since last call and whether it's stable. The compiler generates early-exit code:

fun Greeting(name: String, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(0x12345)

// If name didn't change and other params didn't either, skip
if ($composer.skipping && ($changed and 0b1) == 0) {
$composer.skipToGroupEnd()
} else {
// actual body
Text("Hi $name", ...)
}

$composer.endRestartGroup()?.updateScope { /* ... */ }
}

Group keys

Each composable call gets a positional key derived from source-file + line:

Column { // startReplaceableGroup(key = 0x7A8B9C)
Text("A") // startReplaceableGroup(key = 0x8B9CAD)
Text("B") // different line → different key
}

This is the basis of positional memoization.

remember as compiler magic

val state = remember { mutableStateOf(0) }

Compiles to something like:

val state = $composer.cache(false) { mutableStateOf(0) }
// cache() reads from the slot table; only runs the lambda the first time

The slot table cursor position determines which cached value you get. This is why remember inside an if branch resets when the condition changes — the cursor position changes, so a different slot is used.


Why your composable has TWO bodies

The compiler actually generates three paths through your composable:

  1. Initial composition — full execution, populates slot table.
  2. Recomposition with changed inputs — re-runs, updates slots.
  3. Recomposition with same inputs — skips via early-exit (skippable case).

This is how the same source function serves all three scenarios without you writing any boilerplate.


Debugging internals

Compose compiler metrics

// build-logic/convention/AndroidComposeConventionPlugin.kt
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
if (project.findProperty("composeMetrics") == "true") {
val reportDir = project.layout.buildDirectory.dir("compose-reports").get().asFile.absolutePath
freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$reportDir",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$reportDir"
)
}
}
}

./gradlew :app:assembleRelease -PcomposeMetrics=true produces:

  • -classes.txt — every class with stability: stable / unstable
  • -composables.txt — every @Composable with restartable skippable scheme(...)
  • -module.json — aggregate counts

An unstable class or non-skippable composable is a performance fix.

Layout Inspector — recomposition counts

Android Studio → View → Tool Windows → Layout Inspector → enable Recomposition counts. Each composable shows composition count and skip count. Target: list items should show high skip counts — anything composing on every frame is wasted work.

snapshotFlow { println(Snapshot.current) } — see the snapshot ID

LaunchedEffect(Unit) {
snapshotFlow { Snapshot.current.id }
.collect { println("Snapshot $it") }
}

Each state mutation advances the snapshot; this log traces every commit.


Common misconceptions

Incorrect models

Common misunderstandings

  • "Compose rebuilds the UI from scratch every frame"
  • "@Composable functions run on every frame"
  • "LazyColumn composes all items at once"
  • "remember is just like a static variable"
  • "Recomposition is the only cost"
  • "@Stable makes a class immutable"
Correct models

What really happens

  • Only invalidated scopes re-run; the rest is cached in the slot table
  • They run only when state they read changes
  • LazyColumn composes + retains only visible items + small buffer
  • remember is slot-table-indexed by call position
  • Layout and draw phases also have costs — graphicsLayer skips composition
  • @Stable is a promise the class reports mutations via State; it doesn't prevent mutation

Key takeaways

Practice exercises

  1. 01

    Inspect the compiler metrics

    Enable composeMetrics=true, run assembleRelease, and find three unstable classes in your codebase. Fix them.

  2. 02

    Layout Inspector recomposition counts

    Open a screen with a LazyColumn. Identify items with low skip counts. Investigate why and apply @Immutable / keys as needed.

  3. 03

    Slot table experiment

    Put a TextField inside an if (showName) branch that flips. Observe that the TextField loses focus/text when the branch re-enters. Wrap in key { } and verify the fix.

  4. 04

    Stability contract violation

    Create an @Immutable data class with a var property. Mutate it mid-composition. Observe that Compose doesn't recompose — because you violated the contract.

  5. 05

    Snapshot observer

    Use snapshotFlow + Snapshot.current.id to log every snapshot commit in your app for 60s. Count commits; spot any runaway state churn.

Further reading

Next

You've now covered every corner of Jetpack Compose. Return to Module 03 Overview or continue to the next module: Module 04 — Architecture & Patterns.