Jetpack Compose has revolutionized Android UI development with its declarative, reactive paradigm. While this model simplifies code and boosts developer productivity, it introduces a new performance model centered around recomposition—the process of re-executing composable functions when data changes. A poorly optimized Compose app can suffer from jank, dropped frames, and high battery consumption due to excessive or inefficient recomposition. This article serves as a deep dive into the practical strategies and best practices for ensuring your Jetpack Compose apps are not only beautiful and functional but also silky-smooth and performant.
Recomposition is the heart of Compose. When the state a composable reads changes, Compose schedules a recomposition to update the UI. The key to performance is making this process as lean and targeted as possible.
A fundamental principle is positional memoization. Compose skips recomposition if it can determine that all inputs to a composable are the same as the previous invocation. Your optimization efforts should focus on enabling this skipping behavior.
One of the most common performance pitfalls is causing recomposition in a wider scope than necessary.
State Hoisting and Lambdas: When you pass a lambda to a composable (e.g., an onClick callback), avoid reading state directly inside it if it will be executed later. Instead, hoist the required state as parameters to the lambda.
val scrollState = rememberScrollState()
Button(
onClick = { /* ... */ },
modifier = Modifier.onPointerEvent(PointerEventType.Scroll) {
// Reading scrollState here causes the entire Button to recompose on every scroll tick.
scrollState.dispatchRawDelta(it.changes.first().scrollDelta.y)
}
) { Text("Scroll Me") }
val scrollState = rememberScrollState()
val scrollHandler = remember(scrollState) {
{ delta: Float -> scrollState.dispatchRawDelta(delta) }
}
Button(
onClick = { /* ... */ },
modifier = Modifier.onPointerEvent(PointerEventType.Scroll) {
// Now the Button is stable; the handler is remembered.
scrollHandler(it.changes.first().scrollDelta.y)
}
) { Text("Scroll Me") }
Use derivedStateOf for Expensive Calculations: If you have a state that is derived from others and its calculation is expensive, use derivedStateOf. It will only trigger recomposition when the result actually changes.
val listState = rememberLazyListState()
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
// Only recomposes when `showButton` changes from true/false, not on every scroll pixel.
if (showButton) {
FloatingActionButton(onClick = { /* ... */ }) { /* ... */ }
}
Read State at the Right Level: Read a state in the composable that directly uses it. Don't read a state in a parent and pass it down as a parameter if the parent doesn't need it for its own logic.
For long lists or grids, LazyColumn and LazyRow are non-negotiable for performance. They only compose and lay out the items that are currently visible on the screen.
key for each item. This allows Compose to correctly identify items across recompositions, reusing existing compositions when the data set changes (e.g., through reordering or item updates), which is far more efficient.LazyColumn {
items(
items = users,
key = { user -> user.id } // Unique and stable ID
) { user ->
UserRow(user = user)
}
}
item Content: Keep the composable inside each item as lightweight as possible. Defer image loading, complex text formatting, and other expensive operations.Animations are a common source of jank. Compose provides tools to make them efficient.
animate*AsState for Simple Animations: For animating a single value, use helpers like animateDpAsState or animateColorAsState. They are optimized for recomposition.AnimationSpec: Choose the right AnimationSpec (tween, spring, etc.). A spring can often feel more natural and performant than a long tween.GraphicsLayer for Complex Animations: For properties like rotation, scale, and alpha, apply animations using Modifier.graphicsLayer(). This modifies the drawing instructions without forcing a full layout pass, which is significantly cheaper.val rotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f)
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = "Expand",
modifier = Modifier
.graphicsLayer {
rotationZ = rotation // Animated on the graphics layer, not via layout.
}
)
You can't optimize what you can't measure. Android Studio provides excellent tools for Compose.
As your app grows, architectural decisions have a major impact on performance.
[@Stable](/Stable): Compose's compiler tries to infer the "stability" of a type. A class is stable if it is immutable and all its public properties are known to be stable at compile time. You can use the [@Stable](/Stable) annotation to help the compiler, reducing unnecessary recompositions.LaunchedEffect and side effects judiciously. Don't start a network request or heavy calculation the moment a composable enters composition if it's not yet visible.While the principles of Compose optimization are learnable, applying them effectively in a complex, production-grade application requires deep expertise. Here’s why bringing in specialized talent is a strategic move:
remember, LaunchedEffect, coroutines, and custom Modifier implementations, which are second nature to a specialist.Optimizing Jetpack Compose is a journey of understanding its reactive, declarative mindset. By focusing on intelligent state management, minimizing recomposition scope, leveraging lazy layouts correctly, and using the powerful profiling tools at your disposal, you can build Android applications that deliver an exceptional, fluid user experience. As your app scales, these practices become critical, and investing in skilled Android developers who have mastered them is one of the best guarantees for long-term performance and maintainability.
Nice breakdown! In real Compose apps the biggest performance surprises usually don’t come from Compose itself, but from how recompositions, state hoisting, and unbounded layouts interact with the Android view system and garbage collector.
One metric I often watch early is jank counts at 16ms/frame thresholds under real user flows — that quickly shows where the UI is spending time in recomposition vs drawing.
Curious — in your optimization process, which specific metric or behavior do you treat as the earliest reliable signal that a fix actually improved performance (e.g., lower jank/frame drops, reduced GC pauses, or something else)? That usually shapes where engineers focus their attention first.