Smooth scrolling: measure, then fix recomposition
The screen is redrawn 60 times per second on most phones, and 90 or 120 times on newer ones. That gives the app about 16 milliseconds per frame at 60 Hz (8 at 120 Hz) to handle input, recompose, lay out and draw. Anything that doesn’t fit makes a frame late, and the user feels it.
Two rules before fixing anything:
- Measure release builds. Debug builds are much slower: R8 doesn’t optimize them, Compose runs extra checks, and the code isn’t compiled ahead of time. A debug build stuttering tells you almost nothing.
- Measure on a real, average phone, not the newest flagship or an emulator.
The most common Compose problems are simple: lists without keys, sorting or formatting data inside composables on every recomposition, and reading values that change every frame (like scroll position) in a way that recomposes a big part of the screen.
Measure with Macrobenchmark (FrameTimingMetric for scrolling, StartupTimingMetric for launch) on a release-like build (benchmark build type: minified, non-debuggable, signed with the debug key) on physical devices, and investigate with Perfetto system traces (with Compose composition tracing enabled to see which composables recompose). JankStats reports jank from production. In Compose, the main levers: skipping (stable or immutable parameters let unchanged composables skip; strong skipping mode, the default since Compose compiler 2.0.20, compares unstable parameters by instance, so new lists created on each recomposition still defeat it), lazy list keys and contentType (correct reuse and item state across reorders), deferring state reads to layout or draw phases with lambda modifiers (offset { }, graphicsLayer { }, drawBehind { }) so fast-changing values skip composition entirely, derivedStateOf when a frequently changing input yields a rarely changing output, and moving computation out of composition into the ViewModel or remember.
@Immutable
data class ProductRow(val id: String, val name: String, val priceText: String, val imageUrl: String)
@Composable
fun ProductList(rows: List<ProductRow>, onClick: (String) -> Unit) {
val listState = rememberLazyListState()
// Changes rarely (true/false), even though the scroll index changes constantly
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }
Box {
LazyColumn(state = listState) {
items(
items = rows,
key = { it.id }, // stable identity across inserts and reorders
contentType = { "product" }, // reuse compositions of the same type
) { row ->
ProductRowItem(row, onClick) // skipped when row and onClick are unchanged
}
}
if (showScrollToTop) ScrollToTopButton(listState)
}
}
@Composable
fun CollapsingHeader(scrollState: ScrollState) {
Image(
painter = painterResource(R.drawable.header),
contentDescription = null,
// Read the scroll value in the layout phase, not in composition: no recomposition while scrolling
modifier = Modifier.offset { IntOffset(x = 0, y = -scrollState.value / 2) },
)
}key = { it.id }tells Compose which item is which, so inserting a product at the top doesn’t make every row think it changed.ProductRowis@Immutableand already has a formattedpriceTextfrom the ViewModel, so the row does no work and is skipped when it hasn’t changed.derivedStateOfturns “the first visible index”, which changes all the time, into “show the button or not”, which changes rarely. Only that changes cause recomposition.Modifier.offset { … }with a lambda reads the scroll value while placing the image, so the header moves every frame without recomposing.
Tap the line with the bug.
@Composable
fun ProductScreen(products: List<Product>, scrollState: ScrollState) {
val sorted = products.sortedBy { it.name }
Column(Modifier.verticalScroll(scrollState)) {
HeaderImage(Modifier.offset(y = (-scrollState.value / 2).dp))
sorted.forEach { product ->
ProductCard(
product = product,
price = NumberFormat.getCurrencyInstance().format(product.price),
)
}
}
}Hint: Which value changes on every frame while scrolling, and where is it read?
scrollState.value is read during composition (in Modifier.offset(y = …)), so every scrolled pixel recomposes ProductScreen. Each recomposition then:
- sorts the whole product list again,
- creates a currency formatter for every product (expensive: it loads locale data),
- and composes every card, because a
ColumnwithforEachhas no laziness: all products exist all the time.
Read the scroll position in the layout phase, move sorting and formatting out of composition, and use a lazy list:
@Composable
fun ProductScreen(rows: List<ProductRow>) { // sorted and formatted by the ViewModel
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item(key = "header") {
HeaderImage(Modifier.graphicsLayer {
// Draw phase: no recomposition while scrolling
translationY = if (listState.firstVisibleItemIndex == 0)
-listState.firstVisibleItemScrollOffset / 2f else 0f
})
}
items(rows, key = { it.id }) { row -> ProductCard(row) }
}
}Predict the output
A team measures scrolling in a debug build on an emulator and sees 30% janky frames. They measure the same screen with Macrobenchmark on a benchmark build on a mid-range phone. What's most likely?
Debug builds disable R8 optimizations, run Compose with debug checks and live literals, and execute much of the code in the interpreter. Emulators don't behave like phones either. Numbers from that setup are useless for deciding what to fix. Measure a release-like build (minified, not debuggable) on a real device; Macrobenchmark's benchmark build type exists for exactly this.
- Frame budget ~16 ms at 60 Hz, ~8 ms at 120 Hz. Measure minified non-debuggable builds on physical mid-range devices: Macrobenchmark (
FrameTimingMetric), Perfetto with composition tracing,JankStatsin production. - Compose: skipping needs stable/immutable params (strong skipping compares unstable params by instance, so fresh lists still recompose); lazy keys and
contentType. - Defer fast-changing reads to layout/draw (
offset {},graphicsLayer {},drawBehind {});derivedStateOffor high-frequency inputs with low-frequency outputs. - No sorting, formatting or allocation-heavy work in composition; precompute in the ViewModel or
remember.
Check this part
Which build should you measure performance on?
What does key = { it.id } in a LazyColumn do?
Why use Modifier.offset { } instead of Modifier.offset(y = …) for a scroll-driven offset?