Jetpack Compose Performance Patterns - Practical Ways to Cut Recomposition and CPU
14 Aug 2025Jetpack Compose is modern, declarative, and fast â when we use it the way it wants to be used. Too many teams still treat Compose like XML-with-data-binding: state scattered, derived values recalculated on every frame, lists reading scroll state per-row⌠and then wonder why the UI jitters on mid-range phones. Letâs fix that.
Below are opinionated, field-tested patterns that minimize recompositions and keep your frames silky. Weâll focus on four workhorses â derivedStateOf, remember, snapshotFlow, and LazyListState â and end with a simple way to see recompositions in debug via Modifier.recompositionHighlighter().
Golden rule: make fewer things observe fewer things
Recomposition is not evil; unnecessary recomposition is. The trick is to:
- Localize state to the smallest scope that needs it.
- Stabilize references so Compose doesnât think parameters have âchangedâ each frame.
- Derive expensive values behind memoization.
- Bridge fast-changing state (like scroll) to coroutines and throttle it.
remember: stabilize objects and expensive work
remember keeps objects stable across recompositions. Use it to:
- Avoid rebuilding expensive objects (formatters, regexes, painters, Path).
- Produce stable lambdas so list rows donât think callbacks changed.
- Cache computations that donât depend on changing inputs.
Bad (allocates every recomposition):
@Composable
fun Price(amount: Long) {
val formatter = NumberFormat.getCurrencyInstance() // rebuilt often
Text(formatter.format(amount / 100.0))
}
Good (stable):
@Composable
fun Price(amount: Long) {
val formatter = remember { NumberFormat.getCurrencyInstance() }
Text(formatter.format(amount / 100.0))
}
Stabilize callbacks per-key
@Composable
fun RowItem(item: Item, onClick: (String) -> Unit) {
val stableOnClick = remember(item.id) { { onClick(item.id) } }
ListItem(modifier = Modifier.clickable(onClick = stableOnClick)) {
// ...
}
}
Use keys when dependencies change
val painter = remember(url) { /* create painter or ImageRequest */ }
Opinion: if you arenât using remember in list rows and in any constructor-like code, youâre leaving performance on the table.
derivedStateOf: derive once, update only when inputs change
derivedStateOf memoizes a computed value and invalidates it only when the inputs read inside change. This protects you from re-running filters/sorts on every recomposition.
Filtering a list by search query
@Composable
fun UserList(users: List<User>, query: String) {
val filtered by remember(users, query) {
derivedStateOf {
if (query.isBlank()) users
else users.filter { it.name.contains(query, ignoreCase = true) }
}
}
LazyColumn {
items(filtered, key = { it.id }) { user -> Text(user.name) }
}
}
Header elevation based on scroll
@Composable
fun ElevatingTopBar(listState: LazyListState) {
val elevated by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 0
}
}
TopAppBar(
title = { Text("Inbox") },
modifier = Modifier.shadow(if (elevated) 4.dp else 0.dp)
)
}
Opinion: reach for derivedStateOf anytime you write âfilter/sort/map/group/expensive logic inside a composable.â Itâs a low-effort, high-impact micro-optimization.
snapshotFlow: turn Compose state into a Flow (and throttle it)
LazyListState changes a lot while you scroll. Reading it directly in many places will cause many recompositions. Instead, transform state to a Flow and react off the main composition.
Show âScroll to topâ only when scrolled
@Composable
fun ScrollAwareFab(listState: LazyListState, onClick: () -> Unit) {
var show by remember { mutableStateOf(false) }
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 0
}
.distinctUntilChanged()
.collect { show = it }
}
AnimatedVisibility(visible = show) {
FloatingActionButton(onClick = onClick) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
Throttle expensive reactions (e.g., analytics, network)
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.debounce(150) // donât spam
.collect { index -> viewModel.onSectionVisible(index) }
}
Opinion: scroll is a firehoseânever let it directly drive composables in a tight loop. snapshotFlow + distinctUntilChanged/debounce is the clean pattern.
LazyListState patterns: isolate, key, and avoid per-row observers
Create it once
val listState = rememberLazyListState()
LazyColumn(state = listState) { /* ... */ }
Donât read listState in each item. Compute a derived boolean in the parent and pass just that down.
Bad:
items(items) {
// each row reads!
val elevated = listState.firstVisibleItemScrollOffset > 10
Cell(elevated)
}
Good:
val elevated by remember {
derivedStateOf {
listState.firstVisibleItemScrollOffset > 10
}
}
items(items, key = { it.id }, contentType = { "cell" }) { item ->
Cell(elevated) // stable boolean
}
Always provide a stable key and (when useful) contentType
LazyColumn(state = listState) {
items(
items = uiModels,
key = { it.id }, // keeps item state when reordered
contentType = { it.type } // lets LazyColumn reuse layouts
) { model -> RowFor(model) }
}
Prefer immutable models
@Immutable
data class UiModel(
val id: String, val title: String, val type: String)
Immutable (or stable) models + proper keys = fewer invalidations across the list.
Compose effects: keep them stable with rememberUpdatedState
When a long-lived effect (like LaunchedEffect(Unit)) needs the latest lambda or value, avoid re-starting the coroutine whenever that value changes. Use rememberUpdatedState.
@Composable
fun EventConsumer(events: Flow<Event>, onEvent: (Event) -> Unit) {
val currentOnEvent by rememberUpdatedState(onEvent)
LaunchedEffect(events) {
// no unnecessary restart
events.collect { e -> currentOnEvent(e) }
}
}
Putting it together: a smooth list screen
@Composable
fun MessagesScreen(
all: List<Message>,
query: String,
onMessageClick: (String) -> Unit
) {
val listState = rememberLazyListState()
// 1) Filter efficiently
val filtered by remember(all, query) {
derivedStateOf {
if (query.isBlank()) all else all.filter { it.matches(query) }
}
}
// 2) Derive scroll info once
val showElevatedBar by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 0
}
}
// 3) Drive side-effects via snapshotFlow (debounced)
var showScrollTop by remember { mutableStateOf(false) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex > 0 }
.distinctUntilChanged()
.debounce(120)
.collect { showScrollTop = it }
}
Column {
TopAppBar(
title = { Text("Messages") },
modifier = Modifier.shadow(if (showElevatedBar) 4.dp else 0.dp)
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(
items = filtered,
key = { it.id },
contentType = { "message" }
) { msg ->
val onClick = remember(msg.id) {
{ onMessageClick(msg.id) }
}
MessageRow(message = msg, onClick = onClick)
}
}
AnimatedVisibility(showScrollTop,
modifier = Modifier.align(Alignment.End)) {
FloatingActionButton(onClick = {
// Scroll without rebuilding everything
// (launch in composition scope, doesnât cause
// recomposition storm)
CoroutineScope(Dispatchers.Main).launch {
listState.animateScrollToItem(0)
}
}) {
Icon(Icons.Default.ArrowUpward, contentDescription = null)
}
}
}
}
Debug: see recompositions with recompositionHighlighter
Turn on the highlighter in debug to visualize whatâs rebuilding. Add tooling dep:
dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
// (optional) for Layout Inspector & previews:
debugImplementation("androidx.compose.ui:ui-tooling-preview")
}
Wrap parts of the UI:
@Composable
fun Debuggable(content: @Composable Modifier.() -> Unit) {
Box(Modifier.recompositionHighlighter().then(Modifier.content()))
}
Or just sprinkle it where needed:
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.recompositionHighlighter() // watch this region
) { /* ... */ }
When you scroll or type, anything frequently flashing is suspect. Drill down until only the components that must recompose are flashing.
Opinion: make recompositionHighlighter() part of your daily dev build. Itâs the quickest feedback loop to catch accidental invalidations.
Extra wins (quick checklist)
- Avoid reading fast-changing state in many places. Centralize reads, pass stable derived values down.
- Hoist state so siblings donât observe each other accidentally.
- Prefer
rememberSaveablefor UI state that should survive config changes, remember otherwise. - Use
collectAsStateWithLifecycle()+distinctUntilChanged()when observing flows from a ViewModel to reduce redundant recompositions. - Keep parameters stable. Donât pass new List/Map instances each frame; compute them with
remember/derivedStateOf. - Provide keys and content types in lazy lists. Always.
Why this is modern â and why it matters
Compose is young, and the mental model is different from XML. If you treat it like the old view system, youâll pay in jank and battery. These patterns lean into Composeâs strengths: explicit state, stable inputs, memoized derivations, and coroutine bridges for high-frequency signals. The payoff is immediate: smoother scroll, fewer GC pauses, cooler CPUs, and longer battery lifeâespecially on mid-tier hardware.
Adopt these patterns, keep the highlighter on in debug, and youâll feel the difference in the first build.