Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/AmolPardeshi99/android-performance-skills/llms.txt

Use this file to discover all available pages before exploring further.

Jetpack Compose’s recomposition model introduces performance pitfalls that don’t exist in the View system. The composition phase runs on the main thread, so expensive computation, backwards state writes, and unstable parameters all cause frame drops or infinite recomposition loops.

Heavy work during composition phase

The composition phase runs on the main thread. Any slow computation called directly in a composable body — not inside remember or LaunchedEffect — blocks the main thread on every recomposition, including trivial ones triggered by unrelated state changes.
// ❌ BAD: expensive sort on every recomposition (including trivial state changes)
@Composable
fun ContactList(contacts: List<Contact>, comparator: Comparator<Contact>) {
    val sorted = contacts.sortedWith(comparator)  // O(n log n) on Main, every recompose
    LazyColumn { items(sorted) { ContactRow(it) } }
}

// ✅ CORRECT: wrap in remember(key) — recomputed only when inputs change
@Composable
fun ContactList(contacts: List<Contact>, comparator: Comparator<Contact>) {
    val sorted = remember(contacts, comparator) { contacts.sortedWith(comparator) }
    LazyColumn { items(sorted, key = { it.id }) { ContactRow(it) } }
}

// ✅ BEST: move computation to ViewModel — composable receives a ready-to-render list
class ContactViewModel : ViewModel() {
    val sortedContacts: StateFlow<List<Contact>> = combine(rawContacts, comparatorFlow) { list, cmp ->
        withContext(Dispatchers.Default) { list.sortedWith(cmp) }
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}

Backwards state writes — infinite recomposition loop

Writing to state after reading it in the same composition causes Compose to immediately schedule another recomposition, which reads and writes again, creating an infinite loop that pegs the main thread.
A backwards state write (reading state, then writing it in the same composable body) creates an infinite recomposition loop. The main thread never yields, which manifests as a frozen frame or an ANR. Never mutate state during the composition phase — only inside event handlers or LaunchedEffect.
// ❌ BAD: writes state after reading it → infinite recomposition loop → frozen frame / ANR
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("$count")
    count++   // backwards write: state was read above, now written → endless recompose loop
}

// ✅ CORRECT: write state only inside event handlers or LaunchedEffect; never during composition
@Composable
fun GoodCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count")
    Button(onClick = { count++ }) { Text("Increment") }
}

Reading high-frequency state at the wrong scope

Reading rapidly-changing state (such as scroll offset or animation value) at a parent composable forces the entire subtree to recompose on every frame. The solution is to either narrow the read to a derived state that changes less frequently, or defer the read to the layout or draw phase via a lambda.
// ❌ BAD: reading scroll offset at the top-level composable forces full tree recompose on every scroll
@Composable
fun CollapsingToolbar() {
    val listState = rememberLazyListState()
    val offset = listState.firstVisibleItemScrollOffset  // read here → all children recompose on scroll
    Toolbar(offset = offset)
    LazyColumn(state = listState) { /* ... */ }
}
Use derivedStateOf when you need a boolean or coarse-grained value derived from a high-frequency source. Compose only recomposes when the derived value itself changes, not on every raw state update.
// ✅ CORRECT option A: derivedStateOf — recompose only when first visible item *index* changes
@Composable
fun CollapsingToolbar() {
    val listState = rememberLazyListState()
    val isCollapsed by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
    // Only recomposes when isCollapsed changes (crossing item boundary), not on every pixel scroll
    AnimatedVisibility(visible = isCollapsed) { CollapsedToolbar() }
    LazyColumn(state = listState) { /* ... */ }
}

// ✅ CORRECT option B: defer the read to the leaf composable via lambda (Jetsnack pattern)
@Composable
fun CollapsingToolbar(scrollProvider: () -> Int) {
    val offset = scrollProvider()   // read only during this composable's layout/draw, not its parent's
    // ...
}
// At call site:
CollapsingToolbar(scrollProvider = { listState.firstVisibleItemScrollOffset })

Unstable parameters — forced recomposition

Compose marks a composable as skippable only when all its parameters are stable. Unstable parameters — mutable collections, unannotated complex classes — force recomposition even when the data has not changed.
Annotate data classes with @Immutable or use kotlinx.collections.immutable.ImmutableList to let the Compose compiler prove stability. Check compiler output with the metrics Gradle config shown below.
// ❌ BAD: List<T> is an interface — Compose cannot guarantee it's immutable → unstable
@Composable
fun OrderList(orders: List<Order>) {
    orders.forEach { OrderRow(it) }
}
// Every parent recomposition forces OrderList to recompose, even if orders hasn't changed

// ✅ CORRECT option A: use @Immutable on data classes
@Immutable
data class Order(val id: String, val total: Double)
// Now Compose can skip OrderList if 'orders' reference hasn't changed

// ✅ CORRECT option B: use Kotlinx Immutable Collections
@Composable
fun OrderList(orders: ImmutableList<Order>) {  // kotlinx.collections.immutable.ImmutableList
    orders.forEach { OrderRow(it) }
}

// ✅ CORRECT option C: if mutation is needed, isolate state in ViewModel; expose StateFlow<List<T>>

Missing key in LazyColumn/LazyRow

Without stable keys, Compose uses positional identity for list items. When items are added, removed, or reordered, every item below the changed position is considered new, triggering a full recompose and rebind for the entire visible list.
// ❌ BAD: no key — item at position 0 changes → every other item recomposed
LazyColumn {
    items(notes) { note ->
        NoteRow(note)
    }
}

// ✅ CORRECT: stable key — Compose knows which item moved, recomposes only changed ones
LazyColumn {
    items(notes, key = { note -> note.id }) { note ->
        NoteRow(note)
    }
}

// ✅ For indexed lists:
LazyColumn {
    itemsIndexed(orders, key = { _, order -> order.orderId }) { index, order ->
        OrderRow(index, order)
    }
}

Side effects in the composable body

Running side effects — network calls, Toast, logging, analytics — directly in the composable body executes them during the composition phase on the main thread, on every recomposition.
// ❌ BAD: network call directly in composition — blocks Main on every recompose
@Composable
fun ProductScreen(productId: String) {
    val product = runBlocking { productRepo.fetch(productId) }  // BLOCKS Main
    Text(product.name)
}

// ❌ BAD: Toast shown on every recomposition
@Composable
fun WelcomeScreen() {
    Toast.makeText(LocalContext.current, "Welcome!", Toast.LENGTH_SHORT).show()
}

// ✅ CORRECT: async work in LaunchedEffect; one-shot effects with key = Unit
@Composable
fun ProductScreen(productId: String, viewModel: ProductViewModel = hiltViewModel()) {
    LaunchedEffect(productId) {
        viewModel.loadProduct(productId)   // dispatched; does not block Main
    }
    val product by viewModel.product.collectAsState()
    product?.let { Text(it.name) }
}

@Composable
fun WelcomeScreen() {
    val context = LocalContext.current
    LaunchedEffect(Unit) { Toast.makeText(context, "Welcome!", Toast.LENGTH_SHORT).show() }
}

Modifier chain overhead — lambda vs value variants

Some Modifier functions have lambda variants that defer state reads to the draw or layout phase, avoiding full recomposition for frequently-changing values like scroll offset or animation progress. Prefer the lambda form whenever the value changes at animation or scroll frequency.
// ❌ BAD: Modifier.offset(x, y) reads state during Composition phase → full recompose on every scroll
@Composable
fun ScrollingHeader(scrollState: ScrollState) {
    val offset = scrollState.value
    Box(modifier = Modifier.offset(y = (-offset).dp)) { /* header */ }
}

// ✅ CORRECT: Modifier.offset { } lambda is evaluated in Layout phase, not Composition
// → no recomposition triggered; only the layout phase re-runs
@Composable
fun ScrollingHeader(scrollState: ScrollState) {
    Box(modifier = Modifier.offset { IntOffset(0, -scrollState.value) }) { /* header */ }
}

// Similar deferred lambda variants:
// Modifier.graphicsLayer { }    instead of Modifier.graphicsLayer(alpha = ...)
// Modifier.drawBehind { }       instead of Canvas + drawBehind value
// Modifier.background { }       for dynamic colors

remember with expensive inline computation and missing key

Objects created in remember {} without keys are created once and reused for the lifetime of the composable. If the remembered value depends on a parameter that can change, a missing key means stale data is silently served on subsequent recompositions.
// ❌ BAD: always serves the first chart config — stale when dataSet changes
@Composable
fun ChartScreen(dataSet: DataSet) {
    val chartConfig = remember { ChartConfig.from(dataSet) }  // never updates when dataSet changes
    Chart(config = chartConfig)
}

// ✅ CORRECT: invalidate when dataSet changes
@Composable
fun ChartScreen(dataSet: DataSet) {
    val chartConfig = remember(dataSet) { ChartConfig.from(dataSet) }
    Chart(config = chartConfig)
}

Detecting unstable parameters with Compose compiler metrics

The Compose compiler can emit a report for every composable in your module showing whether it is restartable skippable (good — parameters are stable, Compose can skip it) or restartable without skippable (parameters are unstable, recomposition cannot be skipped). Enable metrics output in your app module’s build file:
// build.gradle.kts (app module)
android {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir}/compose-metrics",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir}/compose-reports"
        )
    }
}
// After build: check build/compose-reports/<module>-composables.txt
// Look for: "restartable skippable" (good) vs "restartable" without "skippable" (unstable parameters)
Run a debug build, then open build/compose-reports/<module>-composables.txt. Every composable listed as restartable but not skippable is a candidate for the @Immutable or ImmutableList fixes shown in the stability section above.

Build docs developers (and LLMs) love