SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:23:03
UP00:01
MOD · ARTICLE · JETPACK COMPOSES/N · AX-JETPACPUBLISHED
All Articles
Jetpack Compose · Updated 11 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 does not mutate views. It re-runs composable functions when their inputs change and applies the difference. The cost model follows from one sentence in the best practices guide: "Composable functions can run very frequently, as often as for every frame of an animation." Anything you do in a composable body, you may be doing on every frame. Most Compose performance advice written before 2025 is about one problem: getting the compiler to skip functions whose inputs did not change. That advice, including the earlier version of this article, predates strong skipping. The compiler now does most of it for you, and two sections below were rewritten because what they told you to do by hand is done automatically. Before tuning anything, measure the right build. The performance overview is direct: "Debug mode is useful for spotting many problems, but it imposes a performance cost and can make it hard to spot other issues." Run a release build with R8 enabled and a baseline profile before you believe a number.

Does stability still matter with strong skipping?

Less than it did, and in a different way. The old rule was that one unstable parameter, a plain List for example, made a composable unskippable. This article used to say so. That stopped being the default with Kotlin 2.0.20. Strong skipping is "enabled by default in Kotlin 2.0.20", and it changes the rule: "With strong skipping enabled, all restartable composable functions become skippable. This applies whether or not they have unstable parameters." What stability decides now is how a parameter is compared:
ParameterCompared withSkipped when
StableObject equality, equals()The new value equals the old one
UnstableInstance equality, ===The very same object is passed again
So a List<User> parameter is no longer a problem by itself. It becomes one when the caller builds a new list on every recomposition, because a new instance never passes ===. The documentation names the common source: "data sources such as Room will allocate new objects for every item in the list any time one of them changes". That is the case where @Stable, @Immutable or an immutable collection still earns its place, because it switches the comparison back to equality. Why List is unstable at all: "the Compose compiler cannot be completely sure that collections such as List, Map, and Set are truly immutable and therefore marks them as unstable" (Fix stability issues). Do not chase a fully skippable codebase. Google's own warning: "Trying to make your entire UI skippable is a premature optimization that could lead to maintenance difficulties in the future" (Diagnose stability issues).
kotlin
// With strong skipping (default from Kotlin 2.0.20) this is skippable.
// It is skipped when the SAME list instance is passed again.
@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users, key = { it.id }) { user -> UserRow(user) }
    }
}

// PROBLEM: a new list instance on every recomposition never
// passes ===, so UserList recomposes every time.
@Composable
fun UsersScreen(all: List<User>, query: String) {
    UserList(users = all.filter { query in it.name })
}

// FIX 1: produce the list once per input change
@Composable
fun UsersScreen(all: List<User>, query: String) {
    val visible = remember(all, query) {
        all.filter { query in it.name }
    }
    UserList(users = visible)
}

// FIX 2: an immutable collection is stable, so it is compared
// with equals() and equal content skips.
import kotlinx.collections.immutable.ImmutableList

@Composable
fun UserList(users: ImmutableList<User>) { /* ... */ }

derivedStateOf: Computed State Without Recomposition Storms

derivedStateOf is for one situation: an input that changes far more often than the result you compute from it. A list's first visible index changes on every item scrolled past. Whether to show a scroll-to-top button changes twice. In the documentation's words, derivedStateOf "creates a new Compose state object you can observe that only updates as much as you need" (Side-effects). It is not a general wrapper for computed values. The same page carries the caution: "derivedStateOf is expensive, and you should only use it to avoid unnecessary recomposition when a result hasn't changed." It also describes the common misuse, combining two state values that change exactly as often as the result does, as "purely overhead and not required". If input and output change at the same rate, a plain expression or remember with keys is the right tool.
kotlin
@Composable
fun MessageList(messages: ImmutableList<Message>) {
    val listState = rememberLazyListState()

    // BAD: read in composition, so this composable recomposes
    // every time the first visible index changes
    // val showButton = listState.firstVisibleItemIndex > 0

    // GOOD: recomposes only when the boolean flips
    val showScrollToTop by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

    Box {
        LazyColumn(state = listState) {
            items(messages, key = { it.id }) { message ->
                MessageBubble(message)
            }
        }

        AnimatedVisibility(
            visible = showScrollToTop,
            modifier = Modifier.align(Alignment.BottomEnd)
        ) {
            ScrollToTopButton()
        }
    }
}

// NOT a use for derivedStateOf: the result changes exactly
// as often as its inputs.
// val fullName by remember { derivedStateOf { "$first $last" } }
val fullName = "$first $last"

Lambdas: What the Compiler Now Does for You

The earlier version of this section told you to wrap callbacks in remember so that child composables would see a stable reference. With strong skipping that is the compiler's job: "With strong skipping enabled, every lambda inside a composable function will be automatically remembered." The generated remember is keyed on what the lambda captures. The section was also wrong about the cause. It called a capturing lambda "unstable". The documentation corrects exactly that: "A common misconception is that lambdas with unstable captures are themselves unstable objects. This is not true. The Compose compiler always considers lambdas to be stable." The old cost came from allocation: a fresh lambda object each recomposition, unequal to the last one. What is left for you is the technique that never depended on the compiler, deferring the state read. "Deferring state reads will ensure that Compose re-runs the minimum possible code on recomposition" (best practices). Pass a lambda that returns the value, or use the lambda form of a modifier, and the read happens in layout or draw. Composition is skipped entirely. That is the same mechanism that keeps animations off the recomposition path.
kotlin
// What you write
@Composable
fun Parent(item: Item, onItemClick: (String) -> Unit) {
    ItemRow(item = item, onClick = { onItemClick(item.id) })
}

// What the compiler generates with strong skipping:
// the lambda is remembered, keyed on its captures.
@Composable
fun Parent(item: Item, onItemClick: (String) -> Unit) {
    val onClick = remember(item, onItemClick) {
        { onItemClick(item.id) }
    }
    ItemRow(item = item, onClick = onClick)
}

// Deferring a read: scroll changes every frame
// BAD: the value is read in composition
Title(snack, scroll.value)

// GOOD: the lambda is read where the value is used
Title(snack) { scroll.value }

// Same idea with a modifier: the lambda form reads in the
// layout phase, so composition does not re-run.
Box(Modifier.offset { IntOffset(0, scroll.value) })

Lazy Layout Performance

LazyColumn and LazyRow compose only what is visible. Two mistakes undo that. No keys. By default an item's identity is its position. Move one item and every position after it looks new. The best practices guide describes the result: "Without your help, Compose doesn't realize that unchanged items are just being moved in the list." Give every item a stable, unique key, and for paged lists take it from the item, never the index. Nesting in the same direction. The earlier version of this article said a LazyColumn inside a vertically scrolling Column would compose every item at once. It does not get that far. The documentation's sample is labelled with the outcome: it throws IllegalStateException. The supported shape is one LazyColumn whose DSL emits the header and the items together.
kotlin
// BAD: no keys, identity is position
LazyColumn {
    items(users) { user -> UserRow(user) }
}

// GOOD: a stable, unique key per item
LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

// BAD: throws IllegalStateException
Column(Modifier.verticalScroll(rememberScrollState())) {
    Text("Header")
    LazyColumn {
        items(users, key = { it.id }) { UserRow(it) }
    }
}

// GOOD: one lazy list, header as an item
LazyColumn {
    item {
        Text(
            "Team Members",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.padding(16.dp)
        )
    }
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

Profiling with Layout Inspector and Compiler Reports

Start with Layout Inspector. "It displays counts of how many times Compose has recomposed or skipped a component" (Diagnose stability issues). A composable with a high recomposition count and almost no skips, on a screen where its data is not changing, is the one to open. The Compose compiler report tells you why. It is off by default, and the configuration block sits at the top level of the module build file, beside android { }. The earlier sample here nested it inside android { } and suggested a -PcomposeCompilerReports flag that does nothing unless your own build script reads it. Both are fixed below. One instruction from the guide matters more than the rest: "Make sure to always run this on a release build to ensure accurate results." Read the report for the cases the stability section described: a class you expected to be stable that is not, feeding a composable that receives new instances. Fix those. Leave the rest alone.
kotlin
// module build.gradle.kts
android {
    // ...
}

// Top level, not inside android { }
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
}

// Run: ./gradlew assembleRelease
// Read build/compose_compiler/<module>-composables.txt
//
// restartable skippable fun SnackCollection(
//   stable snackCollection: SnackCollection
//   stable onSnackClick: Function1<Long, Unit>
// )
//
// and <module>-classes.txt for why a class is unstable:
//
// unstable class Snack {
//   stable val id: Long
//   unstable val tags: Set<String>
// }
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Strong skipping is on by default from Kotlin 2.0.20: every restartable composable is skippable, with or without unstable parameters.
  2. 2Stability now decides the comparison. Stable parameters use equals(), unstable ones use ===, so new instances are what defeat skipping.
  3. 3Lambdas inside composables are remembered by the compiler. Hand-written remember around callbacks is no longer needed.
  4. 4derivedStateOf is expensive. Use it only when an input changes much more often than the result.
  5. 5Give lazy list items stable keys, and never nest a LazyColumn in a vertically scrolling Column: it throws.
  6. 6Measure a release build. Use Layout Inspector counts first, then the compiler report to explain them.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Why is my Compose UI janky?

The usual causes are work done in a composable body on every frame, state read higher in the tree than it is used, lazy lists without keys, and new object instances passed as parameters on each recomposition. Confirm it on a release build first, because debug builds carry a performance cost of their own.

When should I use remember versus derivedStateOf?

Use remember, with keys, to keep the result of a calculation across recompositions. Use derivedStateOf only when an input changes far more often than the value you derive from it, such as scroll position feeding a boolean. The documentation calls derivedStateOf expensive and warns against using it merely to combine two states.

Do I still need @Stable and immutable collections with strong skipping?

Sometimes. Strong skipping makes composables with unstable parameters skippable, but it compares those parameters by instance. If your data layer allocates new objects whenever anything changes, instance comparison fails every time. Marking the class @Stable or passing an immutable collection switches the comparison to equals(), so equal content is skipped.

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