---
title: "Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions"
description: "Master Compose performance with stability annotations, derivedStateOf, remember optimizations, and Layout Inspector profiling techniques."
url: https://android-x.com/blog/jetpack-compose-performance
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-02-20
updated: 2026-10-11
category: "Jetpack Compose"
reading_time: "11 min read"
---

# 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. Published 2026-02-20, updated 2026-10-11. Canonical page: https://android-x.com/blog/jetpack-compose-performance

## 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](https://developer.android.com/develop/ui/compose/performance/bestpractices): "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](https://developer.android.com/develop/ui/compose/performance) 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](https://android-x.com/blog/baseline-profiles-r8-optimization) 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](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping) 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:

| Parameter | Compared with | Skipped when |
|---|---|---|
| Stable | Object equality, `equals()` | The new value equals the old one |
| Unstable | Instance 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](https://developer.android.com/develop/ui/compose/performance/stability/fix)).

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](https://developer.android.com/develop/ui/compose/performance/stability/diagnose)).

```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](https://developer.android.com/develop/ui/compose/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](https://developer.android.com/develop/ui/compose/performance/bestpractices)). 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](https://android-x.com/blog/compose-animation-masterclass) 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](https://developer.android.com/develop/ui/compose/lists) 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](https://android-x.com/blog/paging3-compose-infinite-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](https://developer.android.com/develop/ui/compose/performance/stability/diagnose)). 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>
// }
```

## Key takeaways

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

## Frequently asked questions

### 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.

## Related articles

- [Compose Animation Masterclass: From Micro-Interactions to Shared Element Transitions](https://android-x.com/blog/compose-animation-masterclass.md)
- [Paging 3 with Compose: Infinite Lists Done Right](https://android-x.com/blog/paging3-compose-infinite-lists.md)
- [Baseline Profiles and R8: Advanced Android Performance Tuning](https://android-x.com/blog/baseline-profiles-r8-optimization.md)
