Why Is Your Composable Recomposing Like Crazy? A Debugging Guide

By Engineering Team • Published: 2025-12-10 • Updated: 2025-12-1011 min read

ComposePerformanceRecompositionDebug

The Symptom: List Scrolling Like a Slideshow

The story always starts the same way: PM says "why does this list scroll like a PowerPoint presentation?" You open Profiler and see recomposition count spiking at hundreds per second.

The problem is often not the list itself, but some innocent-looking ViewModel or state update triggering recomposition across a huge subtree. Let us debug this systematically.

Step 1: Enable Recomposition Highlighting

Since Android Studio Hedgehog, Layout Inspector can highlight Composables that are recomposing. Go to Layout Inspector settings and turn on "Show Recomposition Counts". Then interact with your UI and watch which areas are flashing like a disco.

If a component that should not be changing keeps recomposing, that is your suspect.

Step 2: Compose Compiler Metrics

Enable compiler metrics in your build.gradle:

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
}

After building, check the generated .txt files. Watch for two types of functions:

  • restartable but not skippable: These run every time their parent recomposes, even if their parameters have not changed
  • unstable parameters: Parameter types that Compose cannot prove are stable, forcing recomposition

Common Culprit #1: Lambda Captures

Problematic code looks like this:

@Composable
fun UserCard(user: User, viewModel: UserViewModel) {
    Button(onClick = { viewModel.onUserClick(user.id) }) {
        Text(user.name)
    }
}

Every time UserCard recomposes, the onClick lambda is a new instance (because it captures external variables), so Button cannot skip. Fix: wrap the lambda in remember:

@Composable
fun UserCard(user: User, viewModel: UserViewModel) {
    val onClick = remember(user.id) { { viewModel.onUserClick(user.id) } }
    Button(onClick = onClick) {
        Text(user.name)
    }
}

Common Culprit #2: Unstable Data Classes

Compose determines if parameters changed by checking type "stability". List, Map, and other collection types are unstable by default — even if their contents have not changed, they trigger recomposition.

// UNSTABLE — recomposes every time
data class ScreenState(
    val items: List<Item>  // List is unstable
)

// STABLE — can skip correctly
@Immutable
data class ScreenState(
    val items: ImmutableList<Item>  // from kotlinx.collections.immutable
)

Add the kotlinx-collections-immutable library and swap List for ImmutableList, Map for ImmutableMap. Or annotate your data class with @Immutable (but you must guarantee it truly never mutates).

Common Culprit #3: Uncached Derived State

If you compute something inside a Composable, it runs on every recomposition:

// ❌ Filters on every recomposition
@Composable
fun FilteredList(items: List<Item>, filter: String) {
    val filtered = items.filter { it.name.contains(filter) }
    LazyColumn { items(filtered) { ... } }
}

// ✅ Cache with remember
@Composable
fun FilteredList(items: List<Item>, filter: String) {
    val filtered = remember(items, filter) {
        items.filter { it.name.contains(filter) }
    }
    LazyColumn { items(filtered) { ... } }
}

Last Resort: CompositionLocal Abuse Check

If a CompositionLocal value changes frequently, every Composable reading it will recompose. This often happens when you shove an entire ViewModel or large state object into CompositionLocal.

Solution: Only pass truly global values (like theme or locale) through CompositionLocal. Everything else should use parameter passing or dependency injection.

Note: Rule of thumb: If you find yourself "optimizing recomposition", first ask if your architecture is the real problem. Good state decomposition beats manual remember every time.
← Back to Blog