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)Buttonand itsText("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
| Situation | Use derivedStateOf? |
|---|---|
| Expensive filtering from rapidly changing input | Yes |
| “Show scroll-to-top button” based on scroll position | Yes |
| Simple boolean check (list.isEmpty()) | No — too simple |
| State that changes rarely | No — 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:
- Run your app
- Open Tools → Layout Inspector
- Enable Show Recomposition Counts
- Interact with your app
- 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
ImmutableListor enable strong skipping - Are you reading state too early? Use lambda parameters to defer reads
- Is
derivedStateOfneeded? 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
| Problem | Solution |
|---|---|
| List items recomposing when list changes | Add key = { it.id } to items() |
| Expensive computation on every recomposition | Wrap with remember(key) { } |
| Frequent state changes triggering many recompositions | Use derivedStateOf { } |
| Collections causing unnecessary recomposition | Use ImmutableList or enable strong skipping |
| Scroll position causing full recomposition | Pass lambda () -> Int instead of Int |
| Custom class causing recomposition | Add @Immutable or @Stable |
| Need to find what’s recomposing | Use Layout Inspector + recomposition counts |
Source Code
The complete working code for this tutorial is on GitHub:
Related Tutorials
- Tutorial #5: State — state management that drives recomposition
- Tutorial #6: Lists — LazyColumn performance with keys
- Tutorial #15: Animations — keeping animations smooth
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
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.