Introduction
Jetpack Compose makes UI development faster and more intuitive, but performance still matters—especially as your app scales. We've seen teams ship beautiful Compose UIs that mysteriously slow down during everyday interactions, only to discover unnecessary recompositions running dozens of times per frame. Compose performance tips for Android & Kotlin developers often boil down to one principle: recompose only when data actually changes. This article digs into practical strategies we've used in production to keep Compose running smoothly.
Why Recomposition Matters
Compose's declarative model is powerful: you describe what the UI should look like given some state, and Compose figures out how to render it. But this comes with a cost. When state changes, Compose reruns your composable functions to determine what the new UI should be. If you're not careful, a single state change can trigger dozens of unnecessary recompositions throughout your component tree.
Unnecessary recompositions don't just waste CPU cycles. They can cause jank, drain battery, and make your app feel sluggish. The good news is that preventing them isn't magic—it's about being deliberate with how you structure data and pass it around.
Use Stable, Immutable Data Classes
Compose's smart recomposition relies on tracking which values have actually changed. For this to work reliably, your data must be stable. A stable type in Compose means its properties don't change unexpectedly after creation, and Compose can trust equality checks.
The best path forward is immutable data classes. When you use immutable types, Compose can confidently compare old and new values. If the comparison shows no change, it skips recomposition. Here's what we recommend:
data class UserProfile(
val userId: String,
val userName: String,
val avatarUrl: String,
val isOnline: Boolean
)This is stable by default in modern Compose. Compose sees that all properties are immutable primitives or stable types, so it treats the whole class as stable.
Now contrast that with a mutable alternative:
class UserProfileMutable {
var userId: String = ""
var userName: String = ""
var avatarUrl: String = ""
var isOnline: Boolean = false
}Compose won't optimize recomposition for this class because the properties can change at any time. Every time you pass this object to a composable, Compose assumes it might be different and triggers a recomposition.
In production, we've found that converting mutable view models or domain objects into immutable data classes before passing them to composables cuts unnecessary recompositions by 30–50% in typical screens. Use @Immutable annotation if you have custom types that should be stable but Compose can't automatically detect:
@Immutable
data class CustomStableType(
val data: Map<String, String>
)Leverage derivedStateOf for Computed Values
Sometimes you need to derive a new value from existing state—filtering a list, computing a boolean flag, or transforming data. If you compute this value directly in your composable, Compose will recompute it on every recomposition, even if the underlying state hasn't changed. That's wasteful.
derivedStateOf memoizes computed values. It only recomputes when one of its dependencies actually changes, and it prevents downstream recompositions if the derived value stays the same.
Consider a screen showing a list of messages with a search filter:
@Composable
fun MessagesScreen(viewModel: MessageViewModel) {
var searchQuery by remember { mutableStateOf("") }
val allMessages = viewModel.messages.collectAsState()
// Wrong: computed on every recomposition
val filteredMessages = allMessages.value.filter {
it.content.contains(searchQuery, ignoreCase = true)
}
LazyColumn {
items(filteredMessages) { message ->
MessageItem(message)
}
}
}Every time the screen recomposes—even if the search query didn't change—the entire list gets filtered again. For a list of thousands of messages, this adds up.
Here's the corrected version:
@Composable
fun MessagesScreen(viewModel: MessageViewModel) {
var searchQuery by remember { mutableStateOf("") }
val allMessages = viewModel.messages.collectAsState()
val filteredMessages = remember(searchQuery, allMessages.value) {
derivedStateOf {
allMessages.value.filter {
it.content.contains(searchQuery, ignoreCase = true)
}
}
}.value
LazyColumn {
items(filteredMessages) { message ->
MessageItem(message)
}
}
}Now the filter only runs when searchQuery or allMessages actually changes. The composable recomposes, but the expensive filter operation doesn't re-run unnecessarily.
Write Lambdas That Read State Late
Event handlers and callbacks that capture state can accidentally cause recompositions. If you pass a lambda that captures current state to a child composable, and that state changes, the lambda is considered a new object. Compose sees a new object and may recompose the child unnecessarily.
The solution is lambda memoization: capture the minimal state needed, or use lambda-based APIs that defer state reading until the lambda actually runs.
Here's a problematic pattern:
@Composable
fun CartScreen(viewModel: CartViewModel) {
val cart by viewModel.cart.collectAsState()
val total = cart.items.sumOf { it.price * it.quantity }
// Bad: lambda captures 'total', creates new object on every recomposition
Button(
onClick = { viewModel.checkout(total) },
content = { Text("Checkout") }
)
}The onClick lambda captures total. When total changes, a new lambda object is created, and even if the button's other properties are stable, Compose might unnecessarily recompose it.
Better approach:
@Composable
fun CartScreen(viewModel: CartViewModel) {
val cart by viewModel.cart.collectAsState()
// Good: read state inside the lambda, defer computation
Button(
onClick = {
val total = cart.items.sumOf { it.price * it.quantity }
viewModel.checkout(total)
},
content = { Text("Checkout") }
)
}Now the lambda is stable. State is read when the button is clicked, not when the composable renders. For lists or conditionally rendered content, use key() blocks and lambda-accepting APIs to ensure child composables aren't recreated unnecessarily:
items(cartItems, key = { it.id }) { item ->
CartItemRow(item, onRemove = { viewModel.removeItem(item.id) })
}Inspect Recomposition Counts with Layout Inspector
Theory is one thing; seeing actual recomposition behavior is another. Android Studio's Layout Inspector has a recomposition highlighter that shows exactly which composables are recomposing and how often.
To use it:
- Run your app on a device or emulator.
- Open Tools > Layout Inspector.
- Enable Highlight Recompositions (usually in the inspector toolbar).
- Interact with your app. Composables that recompose flash with a colored highlight.
Watch for composables that recompose far more often than you'd expect. If a button recomposes every frame while you're scrolling a list, even though the button's state didn't change, that's a sign of unnecessary recompositions propagating down from a parent.
We've used this tool to identify that a seemingly simple preference toggle was recomposing an entire screen of 50+ items because the parent was restructured to read state too early. Moving the state reading to a lower level in the tree reduced recompositions by 70% on that screen alone.
For more detailed metrics, enable Compose Layout Inspector and look at the "Recompositions" panel. It shows timing information and can highlight expensive operations.
Common Mistakes
Passing mutable objects to composables: If you pass a mutable list or object that's shared across recompositions, Compose can't know if it changed. Always convert to immutable data before passing to composables.
Computing values in the top-level composable: When you compute complex values directly in a composable body instead of memoizing them, you force unnecessary recalculation on every recomposition. Use remember and derivedStateOf liberally.
Ignoring slot recomposition: Passing lambdas as slot APIs (like content parameters) can prevent recompositions of the lambda body if you memoize the lambda. Not doing so forces recomposition of everything inside the lambda.
Over-subscribing to state flows: Collecting state at a high level in the tree means more composables depend on that state. If possible, collect state closer to where it's used.
Key Takeaways
- Use immutable data classes and mark custom stable types with
@Immutableso Compose can optimize recomposition decisions. - Memoize computed values with
rememberandderivedStateOfto avoid redundant calculations. - Keep lambdas stable by reading state late, inside the lambda body, rather than capturing it at composition time.
- Use Layout Inspector's recomposition highlighter to identify and fix performance bottlenecks visually.
- Collect state flows and observe state as close as possible to where it's actually used, not at the root of your composable tree.
- Test your Compose UI performance with realistic data sizes and network latency to catch issues before production.






Comments
Loading comments…