Why Your LazyColumn Is Still Janky

Part 4 of the Modern Compose Performance series. Previously: Stop Writing Compose Like It’s 2024, Your Compose App Isn’t Slow — Your State Is, and The Compose Recomposition Mistakes I Keep Seeing in Production. If you’re joining here, this article assumes your state reads are clean and no one is constructing a SimpleDateFormat in a row.

Your state is clean. Your recomposition counts are flat. The compiler report says every row is restartable skippable.

And the feed still stutters.

Not always — that’s the maddening part. It’s smooth on your Pixel, smooth in the office on Wi-Fi, and then someone drops a screen recording in Slack from a mid-range device on 4G where the list visibly hitches every time three new items arrive. You scroll it yourself on the test device and feel it: not a freeze, just a hesitation. A frame that should have taken 8ms took 40.

Here’s what took me longer than I’d like to admit to internalize: the jank you feel while scrolling is usually not caused by scrolling. By the time your finger is moving, Compose has almost nothing left to decide. The real cost was committed earlier — when you declared the list, when you shaped the item, when you decided where an image gets decoded. Scrolling is just the moment the bill arrives.

This article is about that bill. Six list-specific problems, why each one only shows up during scroll, and the before/after fix.

TL;DR: six reasons your list hitches:

  1. No key, or an unstable one — Compose can’t tell your items apart, so it rebuilds them
  2. Nested scroll containers and unbounded height — One misplaced Modifier.verticalScroll composes your whole list at once
  3. Expensive item content — The row is skippable, but the first composition still costs 20ms
  4. Images decoded on the main thread — The one that doesn’t show up in recomposition counts at all
  5. Prefetch tuned for the wrong list — Default cache windows assume light items
  6. Measuring in a debug build — Half the jank you’re chasing doesn’t exist in release

Then: how to tell which of the six you have, in one trace.

First: What a Scroll Frame Actually Has to Do

Sixteen milliseconds on a 60Hz device. Eight on a 120Hz one. Inside that budget, for every frame of a fling, Compose has to:

  1. Figure out which items are now visible (cheap — it’s arithmetic on the scroll offset)
  2. Compose any item that just entered the viewport and wasn’t prefetched (expensive — this is your item’s entire composable function, first run, nothing cached)
  3. Measure and place the visible items (moderate)
  4. Draw (moderate, unless you’re decoding something)

Step 2 is where lists die. A recomposition of an existing row is cheap — that’s what parts 2 and 3 were about. But a first composition of a brand-new row is the full price: every remember cold, every child composable executed, every modifier chain built. Do that inside a scroll frame and you blow the budget.

Which reframes the whole problem. Scroll performance is mostly about two questions:

  • How much does one item cost to create? (mistakes 1, 3, 4)
  • When do we pay for it — ahead of time, or in the frame that needs it? (mistakes 2, 5)

Everything below is one of those two.

1. No key, or a Key That Isn’t Stable

The cheapest fix on this list, and still the most commonly missing one.

❌ Causes unnecessary work — positional identity:

LazyColumn {
items(messages) { message ->
MessageRow(message)
}
}

Without a key, Compose identifies items by index. That’s fine for a static list, and quietly catastrophic for a live one. When three new messages arrive at the top of a chat feed, every item below shifts index — so as far as Compose is concerned, item 4 didn’t move, it became a different item. State keyed to position (remember inside the row, scroll position within a nested row, an expanded/collapsed flag, an in-flight animation) gets reassigned to the wrong content, and the whole visible window re-composes instead of being reused.

You feel it exactly when items arrive: a hitch, and occasionally a visual glitch where an expanded card collapses or an avatar flashes onto the wrong name.

✅ More stable — stable identity, cheap reuse:

LazyColumn {
items(
items = messages,
key = { it.id },
contentType = { it.type } // see below
) { message ->
MessageRow(message)
}
}

Now Compose moves items instead of rebuilding them. Two details that matter in production:

The key must be stable and unique across the whole list. Not the index (that’s the default you’re escaping), not a hash of mutable content, and not UUID.randomUUID() — I have genuinely reviewed that one. If your API can send duplicate IDs across pages, key on something composite like “${page}_${id}” rather than hoping.

contentType is the underused sibling. In a mixed feed — text rows, image cards, ads, date separators — it tells Compose which items share a structure so it can reuse composition slots between them. Without it, every type transition is a fresh build. With it, scrolling a heterogeneous feed gets measurably cheaper, and it costs you one lambda.

2. Nested Scroll and Unbounded Height

This is the bug that turns “lazy” into “eager,” and it’s usually introduced by someone trying to fix a layout problem, not a performance one.

❌ The list stops being lazy at all:

Column(Modifier.verticalScroll(rememberScrollState())) {
ProfileHeader()
StatsRow()
LazyColumn { // infinite height available
items(posts, key = { it.id }) { PostCard(it) }
}
}

Why it happens: someone needed the header to scroll away with the content. Column + verticalScroll is the obvious reach. But a scrollable Column gives its children unbounded height constraints — so the LazyColumn is told “you can be as tall as you like,” and a lazy list with infinite space composes every single item immediately. Two hundred posts, composed and measured before the first frame. (Older Compose versions crashed outright here; now it often “works,” which is worse — it just silently becomes the slowest screen in your app.)

✅ One list, with the header as an item:

LazyColumn {
item(key = "header") { ProfileHeader() }
item(key = "stats") { StatsRow() }
items(posts, key = { it.id }, contentType = { "post" }) { PostCard(it) }
}

The same rule applies to a LazyRow inside a LazyColumn item — that one’s legal (the constraints are bounded in the opposite axis), but give the inner row a rememberLazyListState() that survives, or every horizontal carousel will silently reset its scroll position when you scroll past and come back.

And if you genuinely need a fixed-height list inside a scrolling parent, bound it explicitly (Modifier.height(…) or heightIn(max = …)) so laziness survives. If you find yourself reaching for that often, the answer is usually one LazyColumn with more item types, not two scroll containers nested.

3. The Item That’s Skippable but Expensive

This is the one that defeats everything you learned in part 3, and it’s the reason recomposition counts can look perfect on a janky screen.

Recomposition counts measure repeat work. They tell you nothing about the cost of a row’s first composition — which, during a fling, happens dozens of times as new rows enter the viewport.

❌ Causes jank the profiler’s recomposition column will never show:

@Composable
fun PostCard(post: PostUi) {
Card(Modifier.dropShadow(shape = cardShape, shadow = Shadow(radius = 24.dp))) {
Column {
Box(
Modifier
.background(Brush.linearGradient(post.gradientStops)) // built per item
.blur(20.dp) // expensive
) {
AuthorRow(post.author)
}
Text(buildAnnotatedString { // parsed per item
appendMarkdown(post.body)
})
ReactionBar(post.reactions)
CommentPreview(post.topComments) // 3 more rows inside
}
}
}

Each of these is defensible on its own. Together they make a row that takes 15–25ms to compose from cold — and you need it in under 8 while the user’s finger is moving. Glassmorphism-style effects deserve a specific callout here: blur, layered translucency, and large-radius shadows are draw-phase costs that repeat every frame, not one-time composition costs. A design that looks luxurious on a detail screen can be the single reason a feed can’t hold 120Hz.

✅ Make the cold path cheap, and pay the rest later:

@Composable
fun PostCard(post: PostUi) {
Card(Modifier.dropShadow(shape = cardShape, shadow = postShadow)) { // hoisted constant
Column {
Box(Modifier.background(post.headerBrush)) { // built once in the mapper
AuthorRow(post.author)
}
Text(post.bodyAnnotated) // parsed in the ViewModel
ReactionBar(post.reactions)
            if (post.hasComments) {
CommentPreview(post.topComments) // only when it exists
}
}
}
}

Three moves, in order of payoff:

  1. Move work out of composition entirely — annotated strings, formatted dates, gradient definitions, and brush objects belong in your UI-model mapper, not the row. (This is mistake 1 from part 3, but the multiplier in a list makes it a different-sized problem.)
  2. Hoist constants — shapes, shadows, text styles, and painters that don’t depend on item data should be file-level vals or theme values, not per-item constructions.
  3. Flatten the tree — every nested Box/Column that exists only to apply one modifier is a composition node plus a measure pass, times every row. Modifier chaining, ConstraintLayout, or a custom Layout can collapse three levels into one.

The measurement that proves it: composition tracing, not recomposition counts. Trace a fling and look for the item composable’s first appearance in a frame — if it’s over a few milliseconds, that’s your jank, and no amount of stability work will touch it.

4. Images Decoded in the Frame That Needs Them

If your list shows images and it’s janky, start here — I’ve seen this account for more scroll jank than the other five combined, and it’s completely invisible to Compose tooling.

❌ The frame pays for a full-resolution bitmap:

AsyncImage(
model = post.imageUrl, // 3000x2000 JPEG from the CDN
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(200.dp)
)

Nothing here recomposes excessively. But somewhere in that frame, a 6-megapixel JPEG is being decoded into a bitmap — allocating ~24MB, running the decoder, then downscaling to the 400×200 you actually needed. The allocation churn triggers GC; the GC pause lands mid-fling; you feel a hitch that no Compose tool attributes to anything.

✅ Decode to the size you’ll actually draw:

AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(post.imageUrl)
.size(width = 800, height = 400) // decode to display size, not source size
.crossfade(false) // crossfade animates → per-frame work during scroll
.memoryCacheKey(post.id)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)

The rules I apply to any image list:

  • Request the display size, not the source size. Ask your backend for a resized variant if it can produce one; an image CDN that serves ?w=800 will do more for your scroll performance than any Compose optimization in this series.
  • Disable crossfade in lists. It’s a per-frame animation on every arriving item, and in a fast fling nobody sees it anyway. Keep it on detail screens where it looks great.
  • Give every image a fixed size before it loads, so an arriving bitmap doesn’t trigger a relayout of the whole visible window.
  • Check your memory cache is actually hitting — scroll down and back up with the image loader’s logging on. A cache miss on scroll-back means every returning row re-decodes.

5. Prefetch Tuned for a Lighter List Than Yours

Part 1 introduced two features that pair specifically for this: pausable composition (on by default since the December ’25 release) lets the runtime pause a mid-flight composition when it runs out of frame budget and resume next frame. LazyLayoutCacheWindow (still an experimental opt-in) lets you declare how much content to keep composed ahead of and behind the viewport.

Defaults assume a modest item. If yours is a rich feed card, the default window prefetches one item ahead — and one item ahead isn’t enough when the user flings at 4000 px/s.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Feed(posts: List<PostUi>) {
val state = rememberLazyListState(
cacheWindow = LazyLayoutCacheWindow(ahead = 400.dp, behind = 200.dp)
)

LazyColumn(state = state) {
items(posts, key = { it.id }, contentType = { "post" }) { PostCard(it) }
}
}

This is a trade, not a free win: more prefetch means more memory held and more composition happening off-screen. Tune it with numbers, not vibes — measure jank and memory before and after, on your worst supported device, and expect the sweet spot to be “about one and a half screens ahead,” not “as much as possible.”

One correctness trap that catches teams here, worth repeating from part 1 because it bites hardest in lists: prefetched items are composed before they’re visible. So LaunchedEffect in a row is not an impression signal, and autoplay triggered from composition will start videos the user never sees. Use Modifier.onVisibilityChanged (with onFirstVisible now deprecated as of stable 1.11) and track first-ness yourself.

6. You’re Measuring a Debug Build

Everything above assumes your measurements are real. Half of them usually aren’t.

A debug build can be several times slower than release for Compose specifically — no R8, live literals instrumentation active, and crucially no baseline profile, so every Compose runtime class is interpreted or JIT’d on first use. That last one hits exactly the code paths a cold scroll exercises.

The measurement setup I’d insist on before anyone claims a list is slow or fixed:

  • Release build, R8 on, signed like production.
  • Baseline profile installed — and if your app doesn’t ship one, that’s the highest-leverage fix in this entire article. Generating one for your main scrolling screens routinely delivers double-digit percentage improvements in scroll jank, for zero code changes.
  • Macrobenchmark with scrollAndMeasure-style interaction, reporting FrameTimingMetric. Watch P90 and P99 frame durations, not the average — jank is the tail. An average of 9ms with a P99 of 70ms is a list that feels broken.
  • Your worst supported device, not your Pixel. I keep a deliberately mediocre phone on my desk for exactly this, and it has rejected more “optimizations” than any code review.
  • Same dataset, same network conditions, both runs. A/B comparisons against different data are just noise with extra ceremony.

Finding Yours: One Trace, Six Answers

Rather than trying all six fixes, capture one system trace of a fling on a real device in release mode, with composition tracing enabled, and read it in this order:

  1. Are frames long only when new items appear at the leading edge? → Item creation cost. Mistakes 1, 3, 5.
  2. Is the very first frame after opening the screen enormous? → Unbounded height, the whole list composed at once. Mistake 2.
  3. Do you see Choreographer gaps with GC activity, and image decode work on the main thread? → Mistake 4.
  4. Are item composables showing up repeatedly with the same content? → Missing or unstable keys. Mistake 1. (Layout Inspector confirms it faster.)
  5. Does everything look long, uniformly, including framework code? → You’re on a debug build. Mistake 6. Go back and measure properly.
  6. Are frames fine but draw time high on translucent/blurred rows? → Draw-phase cost from effects. Mistake 3’s second half — and possibly a design conversation rather than a code one.

The Fix-It Checklist

In the order I’d actually work a janky list:

  1. Confirm the measurement — release build, baseline profile, real device, P99 frame time. Everything else is guessing.
  2. Add key — and contentType if the feed is heterogeneous.
  3. Kill nested scroll containers — one LazyColumn, header as item {}.
  4. Audit the image pipeline — request display-size images, crossfade off, fixed dimensions, cache hits verified.
  5. Trace a cold item composition — move string building, formatting, and brush creation to the mapper; hoist constants; flatten redundant layout nodes.
  6. Budget your effects — blur and large-radius shadows are per-frame draw costs; keep the expensive treatment for screens that aren’t flung.
  7. Then tune prefetch — LazyLayoutCacheWindow, measured against memory, on your worst device.
  8. Re-measure and keep the benchmark — a Macrobenchmark you can re-run is how the fix survives the next six sprints.

Final Thoughts

There’s a pattern across the last three articles worth naming. Part 2 said your app isn’t slow, your state is. Part 3 said recomposition isn’t the enemy, unnecessary recomposition is. This one lands in the same place from a different direction:

Scroll jank is rarely a scrolling problem. It’s a “we did the work at the wrong time” problem.

Every fix above moves work to a moment when nobody’s waiting — into the ViewModel mapper, into the prefetch window, into a file-level constant, into an image resized on a server, into a baseline profile compiled at install time. The list doesn’t get faster because the framework got better at scrolling. It gets faster because by the time you fling it, there’s nothing expensive left to do.

That’s the whole discipline, and it’s why “optimize the list” is usually the wrong instruction. The list is fine. It’s the bill we scheduled for the worst possible moment.

This is Part 4 of the Modern Compose Performance series. Next up: Compose Performance — 7 Optimizations That Actually Matter, where we rank everything in this series by real-world payoff and cut the ones that aren’t worth your sprint.

If this found something in your feed screen, a clap (up to 50) helps it reach the next developer about to nest a LazyColumn inside a scrolling Column, and a follow means you’ll catch the ranked optimization list next.

Now settle a debate for me in the comments: what fixed your worst list jank — and was it actually a Compose fix, or did it turn out to be images, network, or a design decision nobody wanted to reopen? (Mine was a beautiful frosted-glass card. It profiled like a crime scene. We kept the design and moved the blur to a static, pre-rendered layer — the designer never noticed, and the feed held 120Hz.)


Why Your LazyColumn Is Still Janky was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.