Jetpack Compose Performance Patterns - Practical Ways to Cut Recomposition and CPU

Jetpack 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:


remember: stabilize objects and expensive work

remember keeps objects stable across recompositions. Use it to:

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)


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.