Performance in Jetpack Compose: Measuring, Understanding, Optimizing

Debunking Compose Performance Myths

Jetpack Compose has accumulated a fair share of folklore since its inception, often based on outdated benchmarks or misattributed issues. As senior developers, we prioritize evidence over anecdotes, so let’s methodically debunk the prevalent myths. This foundation ensures we approach optimization with clarity, focusing on actual bottlenecks rather than perceived ones.

Myth 1: “Compose is inherently slower than traditional Views due to recomposition.” This misconception ignores Compose’s efficient diffing mechanism, similar to React’s reconciliation. Google’s benchmarks show that optimized Compose UIs can outperform View-based equivalents, particularly in dynamic scenarios. Recomposition isn’t a full redraw; it’s targeted. The real culprit is often poor state design leading to excessive invalidations.

For instance, a basic counter:

Kotlin

@Composable
fun CounterExample() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) { Text("Increment") }
    }
}

This recomposes the Column on each increment, but for small trees, it’s imperceptible. In larger apps, myths blame Compose when the issue is cascading state changes.

Myth 2: “Lambdas in Compose have zero cost.” Lambdas are objects, and frequent allocations in loops (e.g., during scrolling) can trigger GC pauses. We’ll explore mitigations in section 5, but hoisting or inlining is key.

Myth 3: “Immutability solves all performance woes.” While it aids stability (section 3), excessive copying can increase allocations. Balance with mutable patterns where appropriate.

Myth 4: “You don’t need profiling for Compose.” Absolutely false. Invisible recompositions require tools like Layout Inspector (section 7) to uncover.

Myth 5: “Compose doesn’t scale for enterprise apps.” Companies like Airbnb and X (formerly Twitter) prove otherwise. Scalability hinges on modular architecture, not the framework.

Consider a “laggy” list example often cited in myths:

Kotlin

@Composable
fun MythicalLaggyList(items: List<String>) {
    LazyColumn {
        items(items.size) { index ->
            val item = items[index] // Inefficient access
            Text(item, modifier = Modifier.padding(16.dp))
        }
    }
}

The lag isn’t Compose’s fault; it’s poor indexing. Switch to items(items) for proper keying, and performance soars. Myths often camouflage suboptimal code.

Debunking these sets us up to understand the real mechanics.

How Recomposition Actually Works

Recomposition is the heartbeat of Compose: when state mutates, affected @Composable functions re-execute to update the UI tree. However, it’s not brute-force; it’s an optimized process leveraging snapshots, scopes, and intelligent skipping.

Compose maintains a composition tree of nodes. Each @Composable defines a scope. State changes (e.g., via MutableState) invalidate scopes, triggering a recompose pass:

  1. Invalidation Phase: Marks dirty scopes based on state dependencies.
  2. Recomposition Phase: Re-executes dirty scopes, generating new nodes.
  3. Application Phase: Diffs and applies changes to the layout tree (Measure/Layout/Draw).

Snapshots ensure consistent reads during recompose, akin to transactional memory.

Conceptually, it’s a dependency graph: composables as nodes, states as reactive edges.

Example with derived state:

Kotlin

@Composable
fun DependentStates() {
    val count by remember { mutableStateOf(0) }
    val evenOdd = derivedStateOf { if (count % 2 == 0) "Even" else "Odd" }
    val display by remember { mutableStateOf("") }

    LaunchedEffect(evenOdd.value) {
        display = "Number is ${evenOdd.value}"
    }

    Column {
        Text(display)
        Button(onClick = { count++ }) { Text("Increment") }
    }
}

Here, derivedStateOf recomputes only on count changes, minimizing recomposes. Without it, direct computation would trigger more frequently.

Under the hood, the Compose compiler transforms functions:

Kotlin

// Simplified pseudocode
fun Text$composable(text: String, composer: Composer) {
    composer.startReplaceableGroup(key = 456)
    // Node emission
    composer.endReplaceableGroup()
}

Groups enable skipping if parameters are unchanged and stable.

Recomposition is partial, but parent recomposes can cascade unless children are skippable.

Understanding this graph is crucial for optimization.

Stability, Immutability, and @Stable / @Immutable

Stability is Compose’s mechanism for optimizing skips during recomposition. A parameter is stable if Compose can reliably use equality checks to detect changes, allowing it to bypass re-execution.

Primitives and strings are inherently stable. For custom types, annotations help:

  • @Immutable: Asserts the type is immutable post-construction. Compose treats it as stable, assuming no mutations.
  • @Stable: Asserts stability, but permits internal mutations if they’re not observable (e.g., via equals/hashCode).

The difference: @Immutable is stricter, for truly frozen data. @Stable is flexible for types with mutable internals but stable external behavior.

Let’s compare with code examples.

First, @Immutable example:

Kotlin

import androidx.compose.runtime.Immutable

@Immutable
data class ImmutableUser(val id: Int, val name: String) // All vals, no mutation

@Composable
fun ImmutableExample(user: ImmutableUser) {
    Text("User: ${user.name}")
    // If user reference unchanged, this skips recompose
}

If the parent passes the same user instance, Compose skips because it’s immutable and equal.

Now, @Stable example for a mutable-but-stable type:

Kotlin

import androidx.compose.runtime.Stable

@Stable
class StableCounter {
    var count: Int = 0 // Mutable internally

    override fun equals(other: Any?): Boolean =
        other is StableCounter && count == other.count

    override fun hashCode(): Int = count.hashCode()
}

@Composable
fun StableExample(counter: StableCounter) {
    Text("Count: ${counter.count}")
    // Skips if counter.count unchanged, despite mutability
}

Here, even if counter.count mutates elsewhere, as long as the value is the same during equality check, it skips. But if mutated during recompose, it could lead to inconsistencies—use cautiously.

Key differences via examples: Suppose a parent composable:

Kotlin

@Composable
fun Parent() {
    val immutableUser = remember { ImmutableUser(1, "Alice") }
    val stableCounter = remember { StableCounter().apply { count = 5 } }

    ChildImmutable(immutableUser)
    ChildStable(stableCounter)
}

@Composable
fun ChildImmutable(user: ImmutableUser) {
    // ...
}

@Composable
fun ChildStable(counter: StableCounter) {
    // ...
}

If parent recomposes but immutableUser and stableCounter are unchanged (by reference for immutable, by value for stable), children skip.

Without annotations, types are unstable, forcing recomposes even if equal.

To clarify differences:

Aspect@Immutable@Stable
Mutation AllowedNo mutations after creation; all properties final.Internal mutations allowed, but must not affect equality observably.
Use CasePure data holders like DTOs, configurations that never change.ViewModel states or objects with controlled mutability (e.g., counters).
Compose BehaviorTreated as stable; equality based on structure.Treated as stable; relies on custom equals/hashCode for change detection.
RiskLow; enforces immutability.Higher; improper equals can lead to skips when changes occurred.
When to UseWhen data is truly immutable, e.g., network responses copied to data classes.When mutability is needed but stability can be guaranteed via APIs.
Example PitfallAttempting mutation throws errors if using frozen types.Mutation mid-recompose causes tearing; use snapshots for consistency.

Use @Immutable for safety in data models. Opt for @Stable when performance demands mutability without full copies, but audit equals rigorously.

Enable Compose reports in build.gradle for stability audits:

groovy

android {
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.10" // Adjust version
    }
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += [
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + buildDir.absolutePath + "/compose_reports"
        ]
    }
}

This generates metrics on unstable parameters.

Stability can halve recompositions in practice.

Avoiding Unnecessary Recomposition

Preventing wasteful recompositions involves strategic state management, keying, and decomposition.

State hoisting: Elevate state to ancestors to limit scopes.

Inefficient:

Kotlin

@Composable
fun LocalStateChild() {
    var count by remember { mutableStateOf(0) }
    Text("$count")
}

Hoisted:

Kotlin

@Composable
fun HoistedParent() {
    var count by remember { mutableStateOf(0) }
    StatelessChild(count) { count++ }
}

@Composable
fun StatelessChild(count: Int, onIncrement: () -> Unit) {
    Text("$count")
    Button(onClick = onIncrement) { Text("++") }
}

Only parent recomposes; child skips if params stable.

Keys in LazyList/LazyRow: Keys uniquely identify items, preserving state and enabling efficient diffing. Without keys, Compose treats items by position, leading to state resets on insertions/deletions/shuffles.

Principle: Lazy layouts use keys for reconciliation, similar to React keys. No key means positional identity—item at index 0 always gets state for position 0, regardless of content. This causes:

  • State loss: Scrolling away and back resets remember-ed values.
  • Inefficient updates: Full recomposes on list changes.
  • Jank: Unnecessary node recreations.

Example without key:

Kotlin

@Composable
fun NoKeyLazyList(items: List<String>) {
    LazyColumn {
        items(items) { item ->
            var clickCount by remember { mutableStateOf(0) }
            Text("$item clicked $clickCount times", modifier = Modifier.clickable { clickCount++ })
        }
    }
}

Shuffle items, and click counts reset because positions remap.

With key:

Kotlin

@Composable
fun KeyedLazyList(items: List<String>) {
    LazyColumn {
        items(items, key = { it }) { item ->  // Use unique key, e.g., item itself if unique
            var clickCount by remember { mutableStateOf(0) }
            Text("$item clicked $clickCount times", modifier = Modifier.clickable { clickCount++ })
        }
    }
}

Keys preserve state across changes. For complex items, use stable IDs.

Impact without: In dynamic lists (e.g., feeds), no keys cause flickering, lost inputs (e.g., text fields), and performance hits from recreating subtrees.

On breaking large composables: Decomposing monolithic functions into smaller ones enhances skippability. If BigComposable invokes smaller ones with subset params like state.header, state.body:

Kotlin

data class AppState(val header: HeaderState, val body: BodyState)

@Composable
fun BigComposable(state: AppState) {
    Header(state.header)
    Body(state.body)
}

@Composable
fun Header(headerState: HeaderState) { /* ... */ }

@Composable
fun Body(bodyState: BodyState) { /* ... */ }

Do the small composables recompose? Only if their params change or parent forces it. If state changes but only body mutates, Header skips (if header stable). Parent recompose checks child params; stable unchanged params mean skip. This isolates recompositions.

Use derivedStateOf for computations:

Kotlin

val filtered = derivedStateOf { items.filter { it.active } }

And LaunchedEffect for side-effects, keying on deps.

These techniques slash recompositions significantly.

Performance Costs of Lambdas, Modifiers, and Slot APIs

Lambdas allocate objects; in recomposition-heavy paths, this pressures GC.

Loop issue:

Kotlin

@Composable
fun LambdaAllocLoop(items: List<String>) {
    items.forEach {
        Text(it, modifier = Modifier.clickable { println(it) }) // New lambda per item per recompose
    }
}

Hoist:

Kotlin

val clickHandler: (String) -> Unit = remember { { println(it) } }
items.forEach {
    Text(it, modifier = Modifier.clickable { clickHandler(it) })
}

Modifiers chain into objects; long chains allocate intermediates.

Compose once:

Kotlin

val baseModifier = remember { Modifier.padding(16.dp).background(Color.Blue) }
Text("Text", modifier = baseModifier.clickable { })

Slot APIs (content lambdas) are lambdas too—same costs. Use composed for caching:

Kotlin

fun Modifier.styled() = composed {
    padding(8.dp).background(useThemeColor())
}

Profile allocations to spot issues.

Lazy Lists at Scale: Real Pitfalls

For massive datasets, LazyColumn/Row excel but falter without care.

Pitfall: No keys (as in section 4).

Nested lazies: Cause measurement overhead; use Pager instead.

Expensive items: Isolate state.

Full scaled example:

Kotlin

import coil.compose.AsyncImage

data class DataItem(val id: String, val type: ItemType, val text: String? = null, val imageUrl: String? = null)

enum class ItemType { TEXT, IMAGE }

@Composable
fun LargeScaleList(items: List<DataItem>) {
    val lazyState = rememberLazyListState()
    LazyColumn(
        state = lazyState,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = items,
            key = { it.id },
            contentType = { it.type }
        ) { item ->
            when (item.type) {
                ItemType.TEXT -> TextItem(item.text ?: "")
                ItemType.IMAGE -> ImageItem(item.imageUrl ?: "")
            }
        }
    }
}

@Composable
private fun TextItem(text: String) {
    Text(text, modifier = Modifier.fillMaxWidth().padding(16.dp).background(Color.Gray))
}

@Composable
private fun ImageItem(url: String) {
    AsyncImage(
        model = url,
        contentDescription = "Image",
        modifier = Modifier.fillMaxWidth().height(200.dp),
        placeholder = ColorPainter(Color.LightGray)
    )
}

Paginate for 100k+ items using Paging library.

Profiling Tools for Compose

Layout Inspector

Visualize tree, recompose counts. Enable experimental features for live updates. Inspect nodes for params, flashes with recomposeHighlighter().

Kotlin

Text("Profiled", modifier = Modifier.recomposeHighlighter())

Compose Tracing

Wrap with trace:

Kotlin

import androidx.tracing.trace

@Composable
fun Traced() {
    trace("Section") {
        // Content
    }
}

View in Perfetto or Profiler.

Use benchmarks for quantification.

Leave a Reply

Your email address will not be published. Required fields are marked *