LazyColumn vs. Scrolling Column in Jetpack Compose: A Decision Guide for Collections
LazyColumn or a scrollable Column? A decision guide covering composition cost, keys, the infinite-constraints crash, and a quick way to validate the difference yourself.
11 Sept 2025, 15:41 UTC

The decision you're actually making
Every Compose screen that shows a list forces the same choice: LazyColumn/LazyRow, or a plain Column/Row wrapped in verticalScroll/horizontalScroll. They look interchangeable for ten items. They are not interchangeable for a thousand, and picking wrong in either direction costs you — either a janky, memory-hungry screen, or needless complexity (and crashes) around a list that never needed laziness.
The useful rule of thumb: if the collection can grow large, is loaded from a data source, or is unbounded, use a lazy layout. If the content is small, fixed, and known at design time, a scrolling Column is simpler and sometimes strictly better.
What each option actually does
A lazy layout composes and lays out only the items visible in the viewport, plus a small prefetch buffer just outside it. As the user scrolls, items entering the screen are composed and items leaving are disposed. Composition cost and memory stay roughly constant whether you have 50 items or 50,000.
A scrollable Column composes and measures every child up front, then scrolls the already-composed content. For 12 settings rows this is fine and even advantageous. For 1,000 chat messages it means 1,000 compositions before the first frame.
Comparison at a glance
| Criterion | LazyColumn / LazyRow | Column + verticalScroll |
|---|---|---|
| Composition cost | Only visible items + buffer | All children, immediately |
| Best for | Large, dynamic, or unbounded data | Small, fixed, known content |
| Item state across reorder | Correct with stable keys | N/A (all items always exist) |
| Intrinsic measurement of all children | Not supported the same way | Supported |
| Heterogeneous content | Multiple item types via contentType | Just write the composables inline |
| Nesting in a scrolling parent | Crashes without bounded height | Works naturally |
| Item animations | Built-in (version-sensitive APIs) | Manual |
Trade-offs worth understanding
Keys are not optional in lazy layouts
Lazy layouts reuse item slots as you scroll. Without a stable, unique key per item, Compose matches state to position, not identity. Reorder or insert items and remembered state — a text field's contents, an expanded flag — leaks into the wrong row. Always pass a key derived from your data's identity:
items(
items = messages,
key = { it.id }, // stable, unique — never the index
contentType = { it.kind } // improves reuse for mixed types
) { message ->
MessageRow(message)
}Using the list index as the key defeats the purpose: indices shift exactly when keys matter (insertions and removals).
The infinite-constraints crash
A LazyColumn measures itself against its parent's constraints. Placed inside a vertically scrolling Column with no bounded height, it receives an infinite height constraint and throws an IllegalStateException at runtime. The fix is almost never "give it a fixed height" — it's to restructure: make the parent itself a single LazyColumn and hoist the surrounding content into item { } blocks:
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { ProfileHeader(user) }
item { SectionTitle("History") }
items(transactions, key = { it.id }) { tx ->
TransactionRow(tx)
}
}When the plain Column genuinely wins
Two real cases. First, intrinsic measurements: if you need all children measured together (for example, sizing siblings to the tallest child), a scrollable Column handles this naturally and lazy layouts do not. Second, simplicity: a settings screen with eight static rows gains nothing from keys, content types, and lazy semantics — the scrolling Column is less code with fewer failure modes.
Version sensitivity
Lazy layout behavior — prefetching, item animations such as animateItem/animateItemPlacement — has changed across Compose releases, and some APIs graduated from experimental only recently. Check which Compose BOM your project pins before relying on a specific animation or prefetch API, and verify against that version's release notes.
Validate the choice with a composition counter
You can confirm the behavioral difference in minutes. Add a counter to your item composable that increments on every composition:
@Composable
fun CountedRow(text: String) {
SideEffect { compositionCount++ } // a top-level var, for diagnostics only
Text(text)
}Run this in a debug build of your app (no special permissions needed; SideEffect runs after each successful composition). Render 1,000 items in a LazyColumn and read the counter after the first frame: you'll see roughly a viewport's worth of compositions. Swap in a Column with verticalScroll and the counter jumps to 1,000 before the first frame completes. Layout Inspector in Android Studio shows the same story visually — only the visible subtree exists in the lazy case.
For key correctness, give one row a remembered TextField, type into it, reorder the data, and scroll. With stable keys the text follows its item; with index keys (or none) it stays glued to the position. To recognize the nesting crash deliberately, drop a LazyColumn inside a verticalScroll Column and watch for the infinite-constraints exception in logcat — knowing that stack trace saves real debugging time later.
Limitations
The composition counter is a diagnostic, not a benchmark — it won't tell you frame times, and debug builds skew performance. For real jank analysis use Macrobenchmark with a release build. Also note that prefetch buffer size and animation behavior vary by Compose version, so re-verify after BOM upgrades.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.