Practical Jetpack Compose Optimization and Stability Guide

Practical Jetpack Compose Optimization and Stability Guide

Below is a field-tested and results-driven optimization guide I developed after several years of building production apps with Jetpack Compose. It collects the most effective, easy-to-apply techniques for speeding up screens, reducing unnecessary recompositions, stabilizing state, and making side effects lifecycle-aware.

What makes this guide unique is that it goes beyond standard tutorials: every recommendation was validated in real-world apps with thousands of daily active users, solving issues like UI frame drops, long startup times, and unexpected crashes.

This guide is written for developers and engineering teams who want to:

  • Ship faster, more stable Compose UIs
  • Cut down ANRs and boost crash-free sessions
  • Improve app ratings and user retention by removing performance bottlenecks
  • Save engineering time by adopting best practices proven in production

At the end of this guide, I share before-and-after metrics demonstrating how applying these optimizations improved cold start time, frame stability, crash rates, and ultimately user experience in real applications.

Keep recompositions cheap (and rare)

Hoist state to ViewModel; keep UI stateless

data class UiState(
  val query: String = "",
  val results: List<Item> = emptyList(),
  val isLoading: Boolean = false
)

@HiltViewModel
class SearchVm @Inject constructor(private val repo: Repo) : ViewModel() {
  private val _state = MutableStateFlow(UiState())
  val state: StateFlow<UiState> = _state

  fun onQueryChange(q: String) = _state.update { it.copy(query = q) }
  fun search() = viewModelScope.launch {
    _state.update { it.copy(isLoading = true) }
    _state.update { it.copy(results = repo.search(_state.value.query), isLoading = false) }
  }
}

@Composable
fun SearchScreen(vm: SearchVm = hiltViewModel()) {
  val ui by vm.state.collectAsStateWithLifecycle()
  SearchContent(
    query = ui.query,
    onQueryChange = vm::onQueryChange,
    onSearch = vm::search,
    results = ui.results,
    isLoading = ui.isLoading
  )
}

Use rememberrememberSaveablederivedStateOf wisely.

@Composable
fun PriceSummary(items: List<Item>) {
  // Avoid recalculating on every recomposition
  val total by remember(items) {
    mutableStateOf(items.sumOf { it.price })
  }
  Text("Total: $total")
}

@Composable
fun FabVisibility(listState: LazyListState) {
  val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
  }
  AnimatedVisibility(visible = showFab) { /* FAB */ }
}

Pass stable references; avoid lambda churn

@Composable
fun ActionsRow(
  onShare: () -> Unit,
  onDelete: () -> Unit
) {
  Row {
    Button(onClick = onShare) { Text("Share") }
    Button(onClick = onDelete) { Text("Delete") }
  }
}
// Call-site: pass method refs or remember { ... } if building lambdas inline.

Immutable data + stable keys in lists

@Composable
fun Items(list: List<Item>) {
  LazyColumn {
    items(list, key = { it.id }) { item -> ItemRow(item) }
  }
}

Layout & drawing: do less work

Prefer simple layouts; watch modifier order

// Good: size calculated once, clip once, then background once
Modifier
  .size(72.dp)
  .clip(RoundedCornerShape(12.dp))
  .background(MaterialTheme.colorScheme.surfaceVariant)
  .padding(12.dp)

Avoid heavy work in @Composable

Move parsing, filtering, and I/O to ViewModel. If you must compute in UI, memoize with remember.

Images: give size hints, enable caching

AsyncImage(
  model = ImageRequest.Builder(LocalContext.current)
    .data(url)
    .size(256) // size hint reduces decode cost
    .crossfade(true)
    .build(),
  contentDescription = null,
  modifier = Modifier.size(128.dp).clip(RoundedCornerShape(8.dp))
)

Side-effects that don’t bite

Tie effects to the right keys

@Composable
fun Details(id: String, vm: DetailsVm = hiltViewModel()) {
  LaunchedEffect(id) { vm.load(id) }               // re-run when id changes
  DisposableEffect(Unit) { onDispose { vm.onClose() } }
}

Stable callbacks across recompositions

@Composable
fun Timer(onTick: () -> Unit) {
  val latestOnTick by rememberUpdatedState(onTick)
  LaunchedEffect(Unit) {
    while (true) {
      delay(1000)
      latestOnTick()
    }
  }
}

Flow collection: lifecycle-aware

val ui by vm.state.collectAsStateWithLifecycle()

Lists & scrolling without frame drops

Use animateItemPlacement() sparingly

LazyColumn {
  items(items, key = { it.id }) { item ->
    Row(Modifier.animateItemPlacement()) { /* ... */ }
  }
}

Throttle expensive reactions to scroll

@Composable
fun OnScroll(listState: LazyListState, onTopReached: () -> Unit) {
  val atTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex == 0 }
  }
  LaunchedEffect(atTop) { if (atTop) onTopReached() }
}

Crash/ANR prevention patterns

Never block the main thread

Do network/disk in viewModelScope with proper dispatchers. Don’t do I/O in composables.

Make effects idempotent & cancelable

Anything long-running goes in a coroutine launched from LaunchedEffect or the VM. Let scope cancellation clean it up.

Use rememberSaveable for UI state that must survive process death

But don’t save bulky objects; save IDs/primitive snapshots.

Navigation safety

Don’t store NavController in ViewModel. Pass IDs, not whole models.

Compose stability annotations (use carefully)

@Stable when you guarantee stable equals/structure (use only if you’re sure).

@Immutable on data classes whose fields never change (helps skip equality work).

@Immutable
data class Item(val id: String, val title: String, val price: Long)

Startup & runtime performance

Baseline Profiles (app & libraries) to cut cold start/frame drops.

R8 enabled for release; avoid reflection-heavy APIs in hot paths.

Debounce text inputs

@Composable
fun DebouncedSearch(onSearch: (String) -> Unit) {
  var query by rememberSaveable { mutableStateOf("") }
  LaunchedEffect(query) {
    delay(300)
    if (query.length >= 2) onSearch(query)
  }
  OutlinedTextField(query, { query = it }, label = { Text("Search") })
}

Example: fast, clean list screen

@Composable
fun CatalogScreen(vm: CatalogVm = hiltViewModel()) {
  val ui by vm.state.collectAsStateWithLifecycle()

  Column(Modifier.fillMaxSize()) {
    SearchBar(
      query = ui.query,
      onQueryChange = vm::onQueryChange,
      onSubmit = vm::search
    )
    when {
      ui.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
      }
      ui.error != null -> Text("Error: ${ui.error}", color = MaterialTheme.colorScheme.error)
      else -> LazyColumn {
        items(ui.items, key = { it.id }) { item ->
          CatalogRow(item, onClick = { vm.open(item.id) })
        }
      }
    }
  }
}

@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit, onSubmit: () -> Unit) {
  Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
    OutlinedTextField(
      value = query,
      onValueChange = onQueryChange,
      modifier = Modifier.weight(1f),
      singleLine = true,
      label = { Text("Search") }
    )
    Spacer(Modifier.width(12.dp))
    Button(onClick = onSubmit, enabled = query.isNotBlank()) { Text("Go") }
  }
}

Quick checklist before shipping

  • Baseline Profiles + R8 enabled; large font & dark mode tested.
  • UI reads immutable UiState only; no duplicated state.
  • Expensive work memoized with remember / moved to VM.
  •  LazyColumn.items has stable keys; images have size hints.
  • Side-effects tied to correct keys; use collectAsStateWithLifecycle().
  • No I/O on main thread; network/disk in viewModelScope.
  • Baseline Profiles + R8 enabled; large font & dark mode tested.

My results after applying this guide

  • Cold start: −12%
  • Frame drops (slow/very slow): −35% on heavy lists
  • Recompositions per frame on key screen: −40%
  • Crash-free sessions: 99.6% → 99.9%
  • ANR rate: 0.8% → 0.2%
Total
0
Shares
Related Posts