Skip to main content

Compose Performance

Compose is fast by default. It becomes slow when you accidentally invalidate things that shouldn't invalidate. This chapter teaches you to see, measure, and fix the three performance killers: unstable inputs, unnecessary recomposition, and layout thrash.

The mental model

Compose render flow
1ReactJSX & state2Shadow TreeImmutable C++3CommitPrioritize updates4MountApply to views5Render60–120fps frames
Composition produces a UI tree of LayoutNodes. Only changed branches recompose.

Each frame, Compose asks: "which composables have changed inputs?" If inputs are equal to last frame (.equals()), the composable is skipped. If any input is unstable, Compose can't prove equality and recomposes.

Stability — the #1 performance lever

A type is stable if:

  1. Its equals() returns the same result for the same inputs forever, AND
  2. Every public property is stable (or marked @Stable).

What Compose considers stable by default

  • Primitives (Int, Boolean, String, Float, ...)
  • Function types (() -> Unit)
  • Enums
  • @Immutable and @Stable annotated types
  • data class with only stable properties (Kotlin 2.0+ inferred)

What is unstable

  • List, Map, Set — they're interfaces; the runtime can't prove immutability
  • MutableState is stable; MutableList is NOT
  • Lambdas that capture an unstable receiver
  • Classes from modules without the Compose compiler plugin

Make it stable

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

// STABLE — annotate the container
@Immutable
data class Inbox(val messages: List<Message>)

// BEST — use Kotlinx Immutable Collections (truly immutable at runtime)
@Immutable
data class Inbox(val messages: ImmutableList<Message>)
// libs.versions.toml
kotlinx-collections-immutable = "0.3.7"
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
val messages: ImmutableList<Message> = messages.toImmutableList()

Compose compiler metrics — the X-ray

Enable compiler metrics to see exactly which composables are unstable, which are skippable, and which are restartable:

// 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"
)
}
}
}

Run:

./gradlew :app:assembleRelease -PcomposeMetrics=true

Inspect build/compose-reports/*-classes.txt:

stable class Message { stable val id: String, stable val body: String }
unstable class Inbox { unstable val messages: List<Message> } // ⚠️ the culprit
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MessageRow
restartable scheme("[androidx.compose.ui.UiComposable]") fun InboxScreen // ⚠️ not skippable

Every unstable class or non-skippable composable is an optimization target.

derivedStateOf — compute once, not per frame

When you compute a value from multiple states inside a composable, Compose recomputes it on every recomposition. derivedStateOf caches the computation and only re-emits when the result changes.

// BAD — `canSubmit` recomputes every char, even when the boolean doesn't change
@Composable
fun Form() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }

val canSubmit = email.contains("@") && password.length >= 8 // recomputes always

SubmitButton(enabled = canSubmit) // invalidates SubmitButton on every keystroke
}

// GOOD — derivedStateOf cache; SubmitButton only recomposes when canSubmit flips
@Composable
fun Form() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }

val canSubmit by remember {
derivedStateOf { email.contains("@") && password.length >= 8 }
}

SubmitButton(enabled = canSubmit)
}

Rule of thumb: use derivedStateOf when the derived value changes far less often than its inputs (e.g., a boolean derived from two strings).

LazyList — stable keys and content type

LazyColumn {
items(
items = messages,
key = { it.id }, // stable identity across reorders
contentType = { it.javaClass } // type hint for item reuse
) { message ->
MessageRow(message)
}
}

Without a key: reorders scroll the wrong item into view, animations re-trigger, item state (e.g., swipe offset) jumps to the wrong row.

Without contentType: when list has heterogeneous item types, Compose can't reuse the right composable slot, causing extra work.

Defer reads — remember { ... } a lambda

If a composable passes state to a child through a lambda, Compose can delay reading the state until composition of the child:

// The `progress` state is read in the parent; parent recomposes on every progress tick
@Composable
fun ScrollToTop(progress: Float) {
Text("Progress: $progress")
}

// Lambda read — ScrollToTop doesn't recompose until children call it
@Composable
fun ScrollProgress(progressProvider: () -> Float) {
Canvas(Modifier.fillMaxWidth()) {
drawRect(color = Blue, size = Size(width = size.width * progressProvider(), height = size.height))
}
}

// Caller
ScrollProgress(progressProvider = { scrollState.value.toFloat() / scrollState.maxValue })

This pattern is huge for animations: reading state inside draw callbacks skips the composition phase entirely.

Modifier allocation — reuse with remember

Every Modifier.padding(8.dp) allocates. For hot leaf composables (inside LazyColumn items), pre-compute:

// BAD — allocates Modifier chain every recomposition
@Composable
fun MessageRow(message: Message) {
Row(modifier = Modifier.padding(12.dp).clip(RoundedCornerShape(8.dp))) { /* ... */ }
}

// GOOD — allocate once
@Composable
fun MessageRow(message: Message) {
val modifier = remember { Modifier.padding(12.dp).clip(RoundedCornerShape(8.dp)) }
Row(modifier = modifier) { /* ... */ }
}

Don't do this prematurely; it's only worth it inside proven hot paths (long list items that re-render often).

Strong skipping mode (Compose 1.7+)

Kotlin 2.0 + Compose Compiler 1.7+ introduced strong skipping: even non-@Stable types are skippable if their reference is equal. This drastically reduces the need to chase stability, but it's still worth fixing unstable collections — reference equality fails when you do a fresh .copy() of your UI state.

// build.gradle.kts
android {
buildFeatures { compose = true }
composeOptions {
kotlinCompilerExtensionVersion = "1.5.15"
}
}
# gradle.properties — enable strong skipping explicitly in older Compose
org.jetbrains.compose.experimental.strong-skipping.enabled=true

Layout Inspector & recomposition counter

In Android Studio, Layout Inspector → Show recomposition counts. Each composable gets two numbers:

  • Composition count — how many times the composable was called
  • Skip count — how many of those were skipped

Target: skip count should dominate composition count for everything inside scrolling lists. If any list item shows high composition with zero skips, that's your culprit.

Baseline profiles for Compose startup

Compose makes heavy use of generics and reified functions, so ART's JIT does a lot of work on first launch. A baseline profile precompiles the hot methods, cutting cold start by 20–40% on real devices.

// baseline-profile/src/main/kotlin/.../BaselineProfileGenerator.kt
@OptIn(ExperimentalBaselineProfilesApi::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()

@Test fun generate() = rule.collect(
packageName = "com.myapp",
profileBlock = {
startActivityAndWait()
device.findObject(By.text("Inbox")).click()
device.wait(Until.hasObject(By.res("InboxList")), 5_000)
val list = device.findObject(By.res("InboxList"))
repeat(3) {
list.fling(Direction.DOWN)
device.waitForIdle()
}
}
)
}

Generate with ./gradlew :app:generateBaselineProfile (on a device). The profile is committed to your repo at app/src/main/baseline-prof.txt. See Module 10 for the full pipeline.

Common anti-patterns

Anti-patterns

Performance killers

  • List<T> in state (unstable)
  • Long lambdas inlined in call sites
  • No key in LazyColumn items
  • Reading state in parents that pass it to children
  • Computing derived values without derivedStateOf
  • Creating Modifier chains inline in hot paths
Best practices

Proven patterns

  • ImmutableList or @Immutable containers
  • Hoist lambdas to stable references
  • Stable key + contentType for LazyColumn
  • Pass lambdas (state providers), not raw state
  • derivedStateOf for computations with small result changes
  • remember { } for reused modifier chains in list items

Debug checklist for "Compose feels slow"

  1. 01

    Enable Layout Inspector recomposition counts

    Find composables with high composition count and low skip count. That's your hot path.

  2. 02

    Run Compose compiler metrics

    ./gradlew assembleRelease -PcomposeMetrics=true and inspect build/compose-reports for unstable classes and non-skippable composables.

  3. 03

    Record a Perfetto trace

    Use Android Studio Profiler → CPU → System Trace. Look for long Choreographer frames. Each dropped frame over 16 ms is a janky scroll.

  4. 04

    Profile macrobenchmark startup

    Use a macrobenchmark test (Module 10) to get cold/warm start distributions across device tiers.

  5. 05

    Apply fixes one at a time

    ImmutableList, derivedStateOf, lambda providers, baseline profile. Measure between each — sometimes only one moves the needle.

Key takeaways

Next

Return to Module 03 Overview or continue to Module 04 — Architecture & Patterns.