Avoiding Hidden Recomposition Traps in Compose
Practical catalog of non-obvious Compose patterns that trigger excessive recomposition, with fixes and measurement strategies for each.
Context
Compose's recomposition model is designed to skip work when inputs have not changed. In practice, several common Kotlin patterns silently defeat this optimization. These traps are "hidden" because the code looks correct, the UI renders properly, and the problem only manifests as degraded frame rates on real devices under load.
Problem
A composable that should be skipped recomposes anyway. The visual output is identical, but the runtime re-executes the function, re-evaluates its children, and wastes frame budget. In isolation, one unnecessary recomposition costs microseconds. In a LazyColumn with 50 visible items, each recomposing unnecessarily, the cost compounds into dropped frames.
Constraints
- The Compose compiler determines stability at compile time, not runtime
- Lambda equality in Kotlin is reference-based, not structural
- Default parameter values are re-evaluated on every call
- Inline composable functions cannot be recomposition scopes
- The compiler treats types from external modules as unstable unless explicitly declared
Design
Trap 1: Lambda Allocations
Every lambda that captures a variable creates a new object on each recomposition. If that lambda is passed as a parameter, the receiving composable sees a new reference and cannot skip.
// Trap: new lambda on every recomposition of ParentScreen
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
val items by viewModel.items.collectAsState()
ItemList(
items = items,
onItemClick = { id -> viewModel.onItemClick(id) } // New lambda every recomposition
)
}
// Fix 1: use a method reference (stable if viewModel is stable)
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
val items by viewModel.items.collectAsState()
ItemList(
items = items,
onItemClick = viewModel::onItemClick
)
}
// Fix 2: remember the lambda
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
val items by viewModel.items.collectAsState()
val onItemClick = remember<(String) -> Unit> { { id -> viewModel.onItemClick(id) } }
ItemList(
items = items,
onItemClick = onItemClick
)
}The Compose compiler can sometimes optimize non-capturing lambdas into singletons. But if the lambda captures any local variable, it allocates on every call.
Trap 2: Collections as Parameters
List, Set, and Map from the Kotlin standard library are interfaces. The compiler cannot prove they are immutable, so it marks them as unstable.
// Trap: List<String> is unstable, FormatPanel always recomposes
@Composable
fun FormatPanel(options: List<String>, selected: String) {
// Even if options has the same contents, it's a new List reference
Row {
options.forEach { option ->
Chip(text = option, isSelected = option == selected)
}
}
}
// Fix: use ImmutableList
@Composable
fun FormatPanel(options: ImmutableList<String>, selected: String) {
Row {
options.forEach { option ->
Chip(text = option, isSelected = option == selected)
}
}
}| Type | Stable? | Alternative |
|---|---|---|
List<T> | No | ImmutableList<T> or PersistentList<T> |
Set<T> | No | ImmutableSet<T> or PersistentSet<T> |
Map<K,V> | No | ImmutableMap<K,V> or PersistentMap<K,V> |
Array<T> | No | Convert to ImmutableList<T> |
String | Yes | N/A |
Int, Float, primitives | Yes | N/A |
Trap 3: Data Classes with Unstable Fields
A data class is stable only if all its fields are stable. One unstable field makes the entire class unstable.
// Unstable: `tags` is List<String>
data class Article(
val id: String,
val title: String,
val tags: List<String>, // Makes entire class unstable
val publishedAt: Instant // Instant from java.time is unstable
)
// Fixed
@Immutable
data class Article(
val id: String,
val title: String,
val tags: ImmutableList<String>,
val publishedAt: Long // Use primitive timestamp
)Alternatively, declare external types as stable in the compiler configuration:
// compose_compiler_config.conf
java.time.Instant
java.time.LocalDate
kotlinx.datetime.Instant
Trap 4: State Hoisting at the Wrong Level
Reading state too high in the tree causes a large subtree to recompose when only a small part needs updating.
// Trap: entire screen recomposes when scrollOffset changes
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
val scrollOffset by viewModel.scrollOffset.collectAsState()
val product by viewModel.product.collectAsState()
Column {
CollapsingHeader(offset = scrollOffset) // Needs scrollOffset
ProductDetails(product = product) // Does NOT need scrollOffset
ReviewsList(productId = product.id) // Does NOT need scrollOffset
}
}
// Fix: isolate the state read
@Composable
fun ProductScreen(viewModel: ProductViewModel) {
val product by viewModel.product.collectAsState()
Column {
// scrollOffset is read inside CollapsingHeader only
CollapsingHeader(viewModel = viewModel)
ProductDetails(product = product)
ReviewsList(productId = product.id)
}
}
@Composable
fun CollapsingHeader(viewModel: ProductViewModel) {
val scrollOffset by viewModel.scrollOffset.collectAsState()
// Only this composable recomposes on scroll
TopAppBar(
modifier = Modifier.graphicsLayer { translationY = -scrollOffset }
)
}Trap 5: Modifier.then() with New Instances
Creating modifier instances inside composition allocates new objects on every recomposition.
// Trap: new Modifier chain on every recomposition
@Composable
fun AnimatedCard(elevation: Dp) {
Card(
modifier = Modifier
.padding(16.dp)
.shadow(elevation, RoundedCornerShape(8.dp)) // new RoundedCornerShape each time
) {
// ...
}
}
// Fix: hoist the shape
private val CardShape = RoundedCornerShape(8.dp)
@Composable
fun AnimatedCard(elevation: Dp) {
Card(
modifier = Modifier
.padding(16.dp)
.shadow(elevation, CardShape)
) {
// ...
}
}Trap 6: derivedStateOf Without Proper Keys
derivedStateOf caches a derived value and only triggers recomposition when the derived value changes. But if used incorrectly, it can cause stale reads or unnecessary recalculations.
// Trap: derivedStateOf recalculates on every composition because
// the lambda captures a recomposition-scoped variable
@Composable
fun SearchResults(query: String, items: ImmutableList<Item>) {
// This creates a new derivedStateOf on every recomposition
val filtered = remember {
derivedStateOf { items.filter { it.name.contains(query) } }
}
// ...
}
// Fix: use remember with keys, or ensure derivedStateOf reads State objects
@Composable
fun SearchResults(query: String, items: ImmutableList<Item>) {
val filtered = remember(query, items) {
items.filter { it.name.contains(query) }
}
// ...
}derivedStateOf is most useful when you have a State object that changes frequently but the derived value changes infrequently (e.g., scroll position to "show FAB" boolean).
Trap 7: Flow Collection Creating New State
// Trap: collectAsState on a new Flow every recomposition
@Composable
fun UserProfile(userId: String, repository: UserRepository) {
// repository.getUser(userId) creates a new Flow each call
// collectAsState subscribes to a new Flow on every recomposition
val user by repository.getUser(userId).collectAsState(initial = null)
// ...
}
// Fix: remember the Flow
@Composable
fun UserProfile(userId: String, repository: UserRepository) {
val userFlow = remember(userId) { repository.getUser(userId) }
val user by userFlow.collectAsState(initial = null)
// ...
}Trade-offs
| Fix | Benefit | Cost |
|---|---|---|
ImmutableList everywhere | Enables skipping for collection params | Conversion overhead at data layer boundary |
| Remember lambdas | Stable references prevent child recomposition | More verbose code, potential stale captures |
| Compiler stability config | Marks external types as stable | Must be maintained as dependencies change |
| Push state reads down | Smaller recomposition scope | More composable functions, slightly deeper call stack |
Failure Modes
Related: Designing Event Schemas That Survive Product Changes.
| Failure | Symptom | Detection |
|---|---|---|
Forgotten lambda allocation in LazyColumn item | All items recompose on any state change | Layout Inspector shows high recomposition counts |
| Unstable data class in hot path | Frame drops during scrolling | Compose compiler metrics show "unstable" for the class |
derivedStateOf with wrong captures | Stale UI or unnecessary recomposition | Unit test comparing expected vs actual recomposition count |
| Flow re-creation on recomposition | Flicker as state resets to initial value | Visible UI reset, logcat showing repeated initial emissions |
Scaling Considerations
- Code review checklist: add stability checks to PR review. Flag
Listparams on composables in scrollable containers - Lint rules: write custom lint checks to detect
Listparameters on composable functions - Module boundaries: types crossing module boundaries lose stability inference. Centralize shared UI models in a common module with
@Immutableannotations - Compose compiler reports: generate on every PR and diff against the baseline. Any composable that becomes non-skippable is a regression
Observability
- Compose compiler metrics:
-P plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=pathgeneratescomposables.txt(skippability),classes.txt(stability) - Layout Inspector: shows recomposition count and skip count per composable in real time
- Custom recomposition counter: track in debug builds
@Composable
inline fun TrackRecomposition(tag: String) {
if (BuildConfig.DEBUG) {
val count = remember { mutableIntStateOf(0) }
SideEffect {
count.intValue++
if (count.intValue > 1) {
Log.d("Recomposition", "$tag recomposed ${count.intValue} times")
}
}
}
}Key Takeaways
- Lambda allocations are the most common hidden recomposition trigger. Use method references or
rememberfor lambdas passed to child composables - Standard Kotlin collections (
List,Set,Map) are unstable. Usekotlinx.collections.immutablefor composable parameters - One unstable field in a data class makes the entire class unstable. Audit with compiler metrics
- Read state in the narrowest possible scope. Do not hoist state reads above the composable that needs them
derivedStateOfis for frequent-input, infrequent-output scenarios. For everything else, userememberwith keys
See also: Debugging Performance Issues in Large Android Apps.
Further Reading
- Jetpack Compose Recomposition: A Deep Dive: A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control ...
- Measuring and Reducing Jank in Compose Apps: A systematic approach to identifying, measuring, and eliminating frame drops in Jetpack Compose applications, with concrete patterns and ...
- Optimizing RecyclerView vs Compose Lists: A side-by-side comparison of RecyclerView and LazyColumn performance characteristics, optimization strategies, and trade-offs for product...
Final Thoughts
These traps are not edge cases. They appear in virtually every Compose codebase. The insidious part is that the app works correctly, and the performance cost is invisible without measurement. Build the habit of checking Compose compiler metrics on every PR, running the Layout Inspector during development, and profiling with Perfetto before releases. The cost of fixing these traps is low. The cost of shipping them to production is measured in jank, poor vitals, and user churn.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.