A Compose screen with a long list is janky while scrolling - walk through how you'd find and fix the recomposition problem causing it.
Jank in a Compose list is usually excessive or unstable recomposition, caused by unstable parameter types, reading state too high in the tree, or lambdas that break skipping, found with the Layout Inspector's recomposition counts and fixed by narrowing what each composable reads.
What the interviewer is scoring
- Does the candidate know what makes a type stable versus unstable to the Compose compiler, and why that matters
- Can they explain smart recomposition as skipping based on parameter equality rather than "Compose is just fast"
- Whether they use a concrete diagnostic tool (Layout Inspector, compose compiler reports) rather than reasoning from first principles alone
- Do they identify state-reading placement as a distinct cause from unstable types
- Can they explain why a lambda created inline in a parent can defeat skipping for a child even when the child's other parameters are stable
Answer
Short answer
Jank in a Compose list is usually excessive or unstable recomposition, caused by unstable parameter types, reading state too high in the tree, or lambdas that break skipping, found with the Layout Inspector's recomposition counts and fixed by narrowing what each composable reads.
Keep android explicit in the answer because that is the concept the interviewer is actually trying to test. A good android explanation names the trade-off, the failure mode, and the evidence you would use before choosing. Use android once more at the decision point so the answer reads as judgement rather than a detached example.
Keep android explicit in the answer because that is the concept the interviewer is actually trying to test. A good android explanation names the trade-off, the failure mode, and the evidence you would use before choosing.
What "recomposition" is actually skipping
Compose's performance model rests on smart recomposition: when state a composable reads changes, Compose re-executes that composable's function, but it can skip re-executing a child composable entirely if the child's inputs are unchanged from last time. That skip is an equality check on the composable's parameters, so the whole mechanism depends on the Compose compiler being able to prove a parameter is stable - meaning its equality check is reliable and its value only changes when Compose is told it changed. Jank on a scrolling list is very rarely "Compose being slow"; it is far more often hundreds of row composables re-executing every frame because skipping never actually engaged, which is a diagnosable and fixable condition rather than an inherent limit.
Finding it before fixing it
Two tools narrow this down without guessing. The Layout Inspector's recomposition counts, viewed while interacting with the running app, show which composables recomposed and how many times per interaction - a row that recomposes on every scroll tick when its content did not change is the direct signal. The Compose compiler's metrics reports (generated at build time) go a level deeper and tell you, per composable function, whether the compiler judged it skippable and whether its class parameters are stable, which turns "why does this recompose" into a specific compiler-reported reason rather than inference from behaviour.
The three causes, and which one it usually is
Unstable parameter types. The compiler treats a type as stable if it is immutable or if all its properties are stable and it correctly implements equality. A data class with a var property, or one holding a plain List<T> rather than an immutable collection type, is judged unstable, because a mutable list can change contents without the reference changing, so equality on the reference tells the compiler nothing trustworthy. A composable receiving an unstable parameter cannot be skipped safely, so it recomposes every time its parent does, regardless of whether the actual value changed.
// Unstable: List is a mutable interface as far as the compiler is concerned,
// so this class cannot be trusted to signal "nothing changed" via equals().
data class RowUiState(val items: List<String>, val isSelected: Boolean)
// Stable: an immutable collection type gives the compiler a real equality
// guarantee, so unchanged rows can be skipped.
data class RowUiState(val items: ImmutableList<String>, val isSelected: Boolean)
State read too high in the tree. A composable that reads a MutableState recomposes when that state changes, and everything inside its function body up to the point where it branches on that state recomposes with it. Reading a value in a parent composable and passing the result down, rather than reading it deep inside a child, means the parent's whole body re-executes on every change even if only one grandchild's text actually needs to update. The fix is to push the state read as far down the tree as possible - into the specific composable that displays it - so only that leaf recomposes.
// Read too high: the whole screen recomposes whenever `count` changes,
// because the read happens before Compose can isolate the change.
@Composable
fun Screen(viewModel: ScreenViewModel) {
val count by viewModel.count.collectAsState()
HeavyHeader()
ItemList()
Text("Count: $count") // only this needs the read
}
// Read pushed down: only the small composable that displays the value
// recomposes; HeavyHeader and ItemList are untouched by count changing.
@Composable
fun Screen(viewModel: ScreenViewModel) {
HeavyHeader()
ItemList()
CountLabel(viewModel.count)
}
@Composable
fun CountLabel(count: StateFlow<Int>) {
val value by count.collectAsState()
Text("Count: $value")
}
Lambdas created inline defeating skipping. A lambda literal written directly inside a parent's body is a new object on every recomposition of that parent unless the compiler can prove it captures nothing that changes, so passing onClick = { viewModel.select(item.id) } inline to a child gives that child a parameter that looks different every time even though the child's other parameters, including item, are perfectly stable. The child cannot be skipped because one of its parameters fails the equality check every time. Remembering the lambda with remember, or restructuring so the callback captures only stable values the compiler can track, restores skippability.
flowchart TD
A[Parent recomposes] --> B{Are all child<br/>params stable and equal?}
B -- Yes --> C[Child skipped, no re-execution]
B -- No, e.g. new lambda instance --> D[Child recomposes<br/>even if visually unchanged]Why this bites hardest in a LazyColumn
A LazyColumn composes each visible row independently, so an unstable row-level parameter or an inline lambda per item multiplies the wasted work by the number of visible rows on screen, every frame of a fling. That is why the symptom is specifically scroll jank rather than a slow one-off screen: the same avoidable recomposition that costs nothing once costs meaningfully when it repeats sixty times a second across a dozen visible rows.
The trap here is fixing only the most visible cause - usually the inline lambda, because it is easy to spot in a diff - and declaring the problem solved because the jank visibly improves. If the underlying state model still has an unstable List property, skipping is still compromised for any other parameter change, and the app will jank again the next time someone adds a field to that same data class. Reading the compiler's stability report for the actual row composable, not just eyeballing the code, is what confirms the fix addressed the root cause rather than the most obvious symptom.
Compose only skips work it can prove is unnecessary - every recomposition pitfall in a list traces back to one parameter the compiler could not trust, whether that is an unstable type, a state read placed too high, or a lambda built fresh every frame.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- What does the Compose compiler's stability inference actually check for a data class with a List property?
- Why does hoisting a MutableState read up to a parent make the parent recompose when only a child visually changes?
- How does key() in a LazyColumn interact with recomposition versus with item reuse?
- What would the compose compiler report show you that Layout Inspector would not?
Related questions
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on performance4 min
- A read-only endpoint that returns fifty thousand rows is slow and memory-heavy in EF Core. What is the context doing?mediumAlso on performance4 min
- How do you choose which devices to test on, and what will an emulator never tell you?mediumAlso on android5 min
- This table has fourteen indexes and writes have got slower. How do you work out which ones to drop?hardAlso on performance6 min