Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions

Master Compose performance with stability annotations, derivedStateOf, remember optimizations, and Layout Inspector profiling techniques.

Introduction

Jetpack Compose makes UI development faster but introduces new performance considerations. Unnecessary recompositions, unstable parameters, and improper state management can cause jank. This guide covers profiling, optimization techniques, and common pitfalls.

Understanding Recomposition

Compose recomposes when state changes. Smart recomposition updates only changed parts. Unstable parameters cause unnecessary recompositions. Use @Stable annotation for classes used as parameters.

Stability and Unstable Parameters

Compose tracks parameter stability. Unstable parameters (like data classes with mutable properties) trigger recomposition even when values haven't changed. Use immutable data classes and @Stable annotation.

derivedStateOf for Expensive Calculations

Use derivedStateOf to memoize calculations based on state. Prevents recalculating on every recomposition. Only recalculates when input state changes. Essential for scroll offsets, filtered lists, and computed values.

Layout Inspector and Profiling

Use Android Studio's Layout Inspector for Compose. Enable 'Show recomposition highlights' in developer options. Use baseline profiles for startup optimization. Profile with Perfetto for detailed analysis.

Frequently Asked Questions

Why is my Compose UI janky?

Common causes: unstable parameters causing excessive recomposition, expensive calculations in composable body, large recomposition scope. Use recomposition highlights to identify problem areas.

When should I use remember vs derivedStateOf?

Use remember for values that should persist across recompositions. Use derivedStateOf for values calculated from other state. derivedStateOf only recalculates when inputs change.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:11
UP00:01
MOD · ARTICLE · JETPACK COMPOSES/N · AX-JETPACPUBLISHED
Jetpack ComposeFeb 20, 202611 MIN

Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions

Master Compose performance with stability annotations, derivedStateOf, remember optimizations, and Layout Inspector profiling techniques.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

How Recomposition Actually Works

Compose's rendering model is fundamentally different from the View system. Instead of mutating existing views, Compose re-executes composable functions when their inputs change, producing a new description of the UI. The Compose runtime then diffs this description against the previous one and applies only the changes. This process -- recomposition -- is designed to be fast. But "designed to be fast" and "actually fast in your app" are different things. Unnecessary recompositions, unstable parameters, and expensive computations during composition are the three most common performance problems in Compose apps. The good news: Compose provides tools to identify and fix all three. The Layout Inspector shows recomposition counts, the compiler reports stability issues, and the framework provides APIs like remember and derivedStateOf to eliminate redundant work.

Stability: The Root of Most Recomposition Problems

Compose can skip recomposition of a composable function when all its parameters are stable and unchanged. A type is stable when Compose can determine at compile time that two instances with the same values are equal. Primitive types, String, and data classes of stable types are stable. Lists, Maps, and classes from external libraries are typically unstable. When a parameter is unstable, Compose must assume it could have changed and recomposes the function every time its parent recomposes -- even if the actual value hasn't changed. This is the most common source of performance problems related to stability.
kotlin
// UNSTABLE: List is a mutable interface
@Composable
fun UserList(users: List<User>) {
    // Recomposes every time parent recomposes
    LazyColumn {
        items(users) { user -> UserRow(user) }
    }
}

// STABLE: Use kotlinx.collections.immutable
import kotlinx.collections.immutable.ImmutableList

@Composable
fun UserList(users: ImmutableList<User>) {
    // Skippable -- only recomposes when list changes
    LazyColumn {
        items(users) { user -> UserRow(user) }
    }
}

// Alternative: wrap in a stable wrapper
@Immutable
data class UserListState(
    val users: List<User>
)

@Composable
fun UserList(state: UserListState) {
    // Skippable!
    LazyColumn {
        items(state.users) { user -> UserRow(user) }
    }
}

// Check stability with the Compose compiler report:
// ./gradlew assembleRelease -PcomposeCompilerReports=true

derivedStateOf: Computed State Without Recomposition Storms

derivedStateOf creates a state object that only triggers recomposition when its computed value actually changes, not when its inputs change. This is critical for situations where an input changes frequently but the derived value changes rarely. The canonical example: a LazyListState's firstVisibleItemIndex changes continuously during scrolling, but a "show scroll-to-top button" boolean only changes when crossing a threshold. Without derivedStateOf, the button's composable recomposes on every scroll pixel. With it, it recomposes only twice.
kotlin
@Composable
fun MessageList(messages: ImmutableList<Message>) {
    val listState = rememberLazyListState()

    // BAD: Recomposes on every scroll event
    // val showButton = listState.firstVisibleItemIndex > 5

    // GOOD: Only recomposes when boolean changes
    val showScrollToTop by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 5
        }
    }

    Box {
        LazyColumn(state = listState) {
            items(messages) { message ->
                MessageBubble(message)
            }
        }

        AnimatedVisibility(
            visible = showScrollToTop,
            modifier = Modifier.align(Alignment.BottomEnd)
        ) {
            FloatingActionButton(onClick = { /* scroll */ }) {
                Icon(Icons.Default.KeyboardArrowUp, null)
            }
        }
    }
}

Lambda Stability and remember

Lambdas that capture changing values are unstable, causing child composables to recompose unnecessarily. The most common case: passing a lambda from a parent that captures a state value. Use remember with a stable key to stabilize lambdas, or hoist the lambda to a scope where it captures only stable references. For event callbacks in lists, be especially careful -- an unstable lambda passed to each item causes every item to recompose when any state in the parent changes.
kotlin
// PROBLEM: Lambda captures 'count', which changes.
// Every recomposition creates a new lambda instance.
@Composable
fun Parent() {
    var count by remember { mutableIntStateOf(0) }
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) { Text("Inc") }
        // Unstable lambda -- recreated every recomposition
        Child(onClick = { doSomething(count) })
    }
}

// FIX: Use rememberUpdatedState for callbacks
@Composable
fun Parent() {
    var count by remember { mutableIntStateOf(0) }
    val currentCount by rememberUpdatedState(count)
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) { Text("Inc") }
        val onClick = remember {
            { doSomething(currentCount) }
        }
        Child(onClick = onClick)  // Stable reference
    }
}

// For list items: use key-based remember
@Composable
fun ItemList(
    items: ImmutableList<Item>,
    onItemClick: (String) -> Unit
) {
    LazyColumn {
        items(items, key = { it.id }) { item ->
            val onClick = remember(item.id) {
                { onItemClick(item.id) }
            }
            ItemRow(item = item, onClick = onClick)
        }
    }
}

Lazy Layout Performance

LazyColumn and LazyRow are efficient by default -- they only compose visible items. But there are common patterns that undermine this efficiency. Always provide a key for items so Compose can track them correctly during reordering, insertion, and deletion. Without keys, Compose uses positional identity, which causes unnecessary recomposition when items shift. Avoid nesting scrollable layouts. A LazyColumn inside a vertically scrollable Column will try to compose all items at once, defeating lazy composition entirely.
kotlin
// BAD: No keys -- items recompose on any list change
LazyColumn {
    items(users) { user -> UserRow(user) }
}

// GOOD: Keys enable efficient diffing
LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

// BAD: Nested scrolling composes ALL items
Column(Modifier.verticalScroll(rememberScrollState())) {
    Text("Header")
    LazyColumn { // BROKEN: infinite height
        items(users) { UserRow(it) }
    }
}

// GOOD: Use LazyColumn's built-in header support
LazyColumn {
    item {
        Text(
            "Team Members",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.padding(Spacing.md)
        )
    }
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

Profiling with Layout Inspector

The Layout Inspector in Android Studio shows recomposition counts and skip counts for every composable in your hierarchy. High recomposition counts with low skip counts indicate a performance problem -- the composable is being re-executed but rarely produces different output. To use it: run your app in debug mode, open Layout Inspector (View > Tool Windows > Layout Inspector), interact with your app, and observe the recomposition counts. Focus on composables with high counts first. The Compose compiler metrics report is another powerful tool. It tells you exactly which classes and parameters are unstable and which composables are not skippable. Run it with the compiler flag and fix the issues it identifies systematically. For a broader view of profiling tools, see the Android performance documentation.
kotlin
// Enable Compose compiler metrics
android {
    composeCompiler {
        reportsDestination = layout.buildDirectory
            .dir("compose_metrics")
        metricsDestination = layout.buildDirectory
            .dir("compose_metrics")
    }
}

// Run: ./gradlew assembleRelease
// Check build/compose_metrics/*-composables.txt
//
// Look for:
// restartable fun UserRow(
//   unstable user: User         <-- FIX THIS
//   unstable onClick: Function0 <-- AND THIS
// )
//
// Target: "restartable skippable" for all composables
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Stability determines whether Compose can skip recomposition -- fix unstable parameters first.
  2. 2Use ImmutableList from kotlinx.collections.immutable or @Immutable wrappers for collection parameters.
  3. 3derivedStateOf prevents recomposition storms from frequently-changing inputs like scroll position.
  4. 4Stabilize lambdas with remember to prevent child composables from recomposing unnecessarily.
  5. 5Always provide keys in LazyColumn/LazyRow for efficient item tracking.
  6. 6Use Layout Inspector recomposition counts and Compose compiler reports to identify bottlenecks.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Why is my Compose UI janky?

Common causes: unstable parameters causing excessive recomposition, expensive calculations in composable body, large recomposition scope. Use recomposition highlights to identify problem areas.

When should I use remember vs derivedStateOf?

Use remember for values that should persist across recompositions. Use derivedStateOf for values calculated from other state. derivedStateOf only recalculates when inputs change.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA