Your app works. But it stutters when scrolling. The screen freezes for a split second when you type. Animations aren’t smooth.

The problem isn’t Compose — it’s recomposition. Compose redraws parts of your UI when state changes. If it redraws too much, too often, your app feels slow.

This tutorial will teach you why Compose gets slow and how to fix it.

How Recomposition Works

When state changes, Compose doesn’t redraw the entire screen. It redraws only the Composables that read the changed state. This is called recomposition.

@Composable
fun Example() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("Static text")       // NOT recomposed when count changes
        Text("Count: $count")     // Recomposed — reads count
        Button(onClick = { count++ }) {
            Text("Add")           // NOT recomposed (doesn't read count)
        }
    }
}

When count changes:

  • Text("Static text")skipped (doesn’t read count)
  • Text("Count: $count")recomposed (reads count)
  • Button and its Text("Add")skipped (don’t read count)

This is smart. But sometimes Compose recomposes things it shouldn’t. That’s when performance drops.

The Three Phases of Compose

Every frame goes through three phases:

1. Composition — WHAT to show (runs @Composable functions)
2. Layout — WHERE to place it (measures and positions)
3. Drawing — HOW to draw it (paints pixels on screen)

Performance problems usually happen in Phase 1 (Composition) — too many Composables recomposing when they don’t need to.

Stable vs Unstable Types

This is the #1 cause of unnecessary recomposition.

Stable Types

Compose can skip recomposition when all parameters are stable and haven’t changed:

// These are STABLE — Compose can skip if they don't change
val name: String = "Alex"           // Primitive — always stable
val count: Int = 42                  // Primitive — always stable
val user: User = User("1", "Alex")  // data class with stable fields — stable

Unstable Types

Compose always recomposes when any parameter is unstable — even if the value didn’t actually change:

// These are UNSTABLE — Compose always recomposes
val users: List<User> = listOf(...)     // List is unstable!
val tags: Set<String> = setOf(...)      // Set is unstable!
val metadata: Map<String, Any> = mapOf() // Map is unstable!

Why are collections unstable? Because Compose can’t guarantee a List won’t change. Someone could pass a MutableList cast to List. Compose plays it safe and always recomposes.

How to Fix It

Option 1: Use Kotlin Immutable Collections

// Add dependency
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")

// Use ImmutableList instead of List
@Composable
fun UserList(users: ImmutableList<User>) {  // STABLE
    LazyColumn {
        items(users) { user -> UserRow(user) }
    }
}

// Convert when calling
UserList(users = userList.toImmutableList())

Option 2: Mark classes as @Stable or @Immutable

// Tell Compose: "I promise this class won't change after creation"
@Immutable
data class Theme(
    val primaryColor: Color,
    val backgroundColor: Color,
    val textSize: Int
)

// Tell Compose: "Properties can change, but I'll use mutableStateOf so you'll know"
@Stable
class Counter {
    var count by mutableStateOf(0)
}

Option 3: Enable Strong Skipping Mode (Easiest)

Strong skipping mode (default since Compose Compiler 1.5.4+) allows Compose to skip composables even with unstable parameters. If you’re on a recent version, this is already enabled.

Check your Compose Compiler version — if it’s 1.5.4+, strong skipping is on by default. Most performance issues from unstable types are already handled.

Using remember Correctly

remember caches values across recompositions. Use it to avoid expensive recalculations:

Expensive Calculations

// BAD — sorts the list on EVERY recomposition
@Composable
fun SortedUserList(users: List<User>) {
    val sorted = users.sortedBy { it.name }  // Runs every time
    LazyColumn {
        items(sorted) { UserRow(it) }
    }
}

// GOOD — sorts only when users change
@Composable
fun SortedUserList(users: List<User>) {
    val sorted = remember(users) {
        users.sortedBy { it.name }  // Runs only when users changes
    }
    LazyColumn {
        items(sorted) { UserRow(it) }
    }
}

remember(key) { } recalculates only when key changes. Without remember, the sort runs on every recomposition — potentially 60 times per second during animations.

Object Creation

// BAD — creates new objects on every recomposition
@Composable
fun MyScreen() {
    val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)  // New object every time!
    // ...
}

// GOOD — create once, reuse
@Composable
fun MyScreen() {
    val dateFormat = remember {
        SimpleDateFormat("yyyy-MM-dd", Locale.US)  // Created once
    }
    // ...
}

When NOT to Use remember

Don’t use remember for cheap operations:

// DON'T — remember is overkill for simple checks
val isEmpty = remember(list) { list.isEmpty() }

// DO — just check directly, it's cheap
val isEmpty = list.isEmpty()

derivedStateOf — Reducing Recomposition Frequency

derivedStateOf creates a derived state that only triggers recomposition when its computed value changes, not when its inputs change.

The Problem

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    val allItems = remember { loadItems() }  // 1000 items

    // BAD — filters and recomposes on EVERY keystroke
    val results = allItems.filter { it.contains(query, ignoreCase = true) }

    TextField(value = query, onValueChange = { query = it })
    Text("${results.size} results")  // Recomposes on every keystroke!
}

When the user types fast, query changes 10+ times per second. Each change triggers filtering AND recomposition.

The Fix

@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    val allItems = remember { loadItems() }

    // GOOD — only recomposes when the RESULT changes
    val results by remember {
        derivedStateOf {
            allItems.filter { it.contains(query, ignoreCase = true) }
        }
    }

    TextField(value = query, onValueChange = { query = it })
    Text("${results.size} results")  // Only recomposes when results.size changes
}

If the user types “a” then “ab”, the filter might return the same number of results. With derivedStateOf, Text("${results.size} results") only recomposes when the count actually changes — not on every keystroke.

When to Use derivedStateOf

SituationUse derivedStateOf?
Expensive filtering from rapidly changing inputYes
“Show scroll-to-top button” based on scroll positionYes
Simple boolean check (list.isEmpty())No — too simple
State that changes rarelyNo — no benefit
// Good use: show button only when scrolled past first item
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

LazyColumn Performance

Always Use Keys

// BAD — Compose can't track items when list changes
LazyColumn {
    items(users) { user -> UserRow(user) }
}

// GOOD — Compose knows which items moved, added, or removed
LazyColumn {
    items(users, key = { it.id }) { user -> UserRow(user) }
}

Without keys, adding an item at the top forces Compose to recompose ALL items. With keys, only the new item is composed.

Use contentType for Mixed Lists

LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { it.type }  // "header", "post", "ad"
    ) { item ->
        when (item) {
            is Header -> HeaderRow(item)
            is Post -> PostRow(item)
            is Ad -> AdRow(item)
        }
    }
}

contentType helps Compose reuse item compositions more efficiently when your list has different item types.

Avoid Nesting LazyColumn Inside LazyColumn

// BAD — nested scrollable lists cause performance issues
LazyColumn {
    item {
        LazyColumn { ... }  // Never do this!
    }
}

// GOOD — flatten into a single LazyColumn
LazyColumn {
    // Section 1
    stickyHeader { Text("Section 1") }
    items(section1Items) { ... }

    // Section 2
    stickyHeader { Text("Section 2") }
    items(section2Items) { ... }
}

Deferring State Reads

Read state as late as possible to minimize recomposition scope:

// BAD — the entire Column recomposes when scrollOffset changes
@Composable
fun Header(scrollOffset: Int) {
    Column {
        Text("Title")
        Box(modifier = Modifier.offset(y = scrollOffset.dp))  // scrollOffset read here
        Text("Subtitle")
    }
}

// GOOD — only the lambda recomposes, not the whole Column
@Composable
fun Header(scrollOffsetProvider: () -> Int) {
    Column {
        Text("Title")
        Box(modifier = Modifier.offset {
            IntOffset(0, scrollOffsetProvider())  // Read inside the layout phase
        })
        Text("Subtitle")
    }
}

By passing a lambda instead of a value, the scroll offset is read during the layout phase (Phase 2), not the composition phase (Phase 1). This avoids recomposing Text("Title") and Text("Subtitle") on every scroll pixel.

Debugging Performance

Layout Inspector

Android Studio’s Layout Inspector shows recomposition counts:

  1. Run your app
  2. Open Tools → Layout Inspector
  3. Enable Show Recomposition Counts
  4. Interact with your app
  5. Red numbers = high recomposition count = potential problem

Composition Tracing

Add to your build.gradle.kts:

implementation("androidx.compose.runtime:runtime-tracing:1.0.0-beta01")

Then use Android Studio Profiler → System Trace to see exactly which Composables are recomposing and how long they take.

Log Recompositions

Quick debugging trick:

@Composable
fun MyScreen() {
    SideEffect {
        println("MyScreen recomposed!")  // Prints on every recomposition
    }
    // ...
}

If you see “MyScreen recomposed!” printing 60 times per second during an animation, something is wrong.

Performance Checklist

Before optimizing, check these first:

  • Are you testing in release mode? Debug builds are 10x slower. Always profile with ./gradlew assembleRelease
  • Is the problem real? Use Layout Inspector to count recompositions. If counts are low, the problem is elsewhere
  • Are you using keys in LazyColumn? Missing keys is the #1 cause of list performance issues
  • Are you creating objects inside Composables? Wrap with remember
  • Are you passing Lists as parameters? Consider ImmutableList or enable strong skipping
  • Are you reading state too early? Use lambda parameters to defer reads
  • Is derivedStateOf needed? Only for expensive computations with rapidly changing inputs

Common Mistakes

Mistake 1: Optimizing Before Measuring

❌ "I'll make every class @Stable just in case"
✅ "Let me measure recomposition counts first, then fix only what's actually slow"

Premature optimization adds complexity without benefit. Measure first.

Mistake 2: Running Performance Tests in Debug

❌ Debug build: 12 FPS — "Compose is slow!"
✅ Release build: 60 FPS — "It's fine."

Debug builds disable compiler optimizations and add debugging overhead. Always test performance in release mode.

Mistake 3: Using derivedStateOf for Everything

// DON'T — overkill for simple operations
val isEmpty by remember { derivedStateOf { list.isEmpty() } }

// DO — just check directly
val isEmpty = list.isEmpty()

derivedStateOf is only useful when the computation is expensive AND the input changes frequently.

Quick Reference

ProblemSolution
List items recomposing when list changesAdd key = { it.id } to items()
Expensive computation on every recompositionWrap with remember(key) { }
Frequent state changes triggering many recompositionsUse derivedStateOf { }
Collections causing unnecessary recompositionUse ImmutableList or enable strong skipping
Scroll position causing full recompositionPass lambda () -> Int instead of Int
Custom class causing recompositionAdd @Immutable or @Stable
Need to find what’s recomposingUse Layout Inspector + recomposition counts

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Testing Compose UI — writing automated tests for your Composables. Testing and performance go together — tests help you catch performance regressions before users notice.

See you there.