What makes a RecyclerView or Compose LazyColumn fast (or slow)?
Both widgets promise smooth infinite scrolling by never fully materializing an offscreen item — but that promise breaks the moment you skip stable keys, granular diffing, or per-item recomposition scoping.
LEARN BY DOINGPut your intuition to work3 steps
What makes a RecyclerView or Compose LazyColumn fast (or slow)?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The shared idea: never pay for what's offscreen
RecyclerView and LazyColumn exist to solve the same problem a naive ScrollView full of children can't: don't inflate, measure, lay out, compose, or hold in memory anything the user isn't currently looking at (plus a small buffer). Everything either widget does well or badly comes back to how faithfully it honors that idea — and the two most expensive operations on either platform are, in order, inflating/composing a brand-new item from scratch and running a full-list update that touches every row. Fast lists minimize both; slow lists trigger them constantly without realizing it.
#RecyclerView: the Recycler's three tiers
RecyclerView's Recycler keeps offscreen views in three progressively "colder" tiers. The attached scrap holds views detached mid-layout-pass that are about to be reattached at a new position — no rebind needed, the cheapest possible reuse, mostly invisible to you as the developer. The cache (sized by setItemViewCacheSize, default 2) holds recently-scrolled-off views that still remember their exact position and data — scroll back up quickly and these come back with zero rebinding at all. Only when a view falls out of the cache does it land in the RecycledViewPool, bucketed by viewType, stripped of any positional identity — reusing one of these always costs a full onBindViewHolder() call, though it skips the much pricier onCreateViewHolder()/inflation. A feed screen with five distinct row shapes and only three pooled instances per type (the default max) will thrash the pool constantly if the pool size isn't raised with setMaxRecycledViews; a nested horizontal RecyclerView (a "stories" rail inside a vertical feed) should share a single RecycledViewPool across every row instance via setRecycledViewPool(), or each row builds and discards its own pool as it scrolls off-screen.
What tips a RecyclerView from fast to slow is almost always one of three habits. First, calling notifyDataSetChanged() for any change, however small — it throws away all positional knowledge and forces a full rebind-and-relayout pass of every visible row, killing item animations in the process. DiffUtil (used directly, or transparently through ListAdapter) computes the minimal set of insert/remove/move/change operations on a background thread and dispatches the granular notifyItem* calls for you. Second, ignoring getChangePayload() — even with DiffUtil, a full content change re-binds the entire row; a payload (say, just a like-count delta) lets onBindViewHolder(holder, position, payloads) update one TextView and skip re-decoding the row's image entirely. Third, not setting setHasStableIds(true) with real, content-derived IDs — without stable IDs, RecyclerView can't cleanly tell "this is the same logical item that moved" from "this position now holds different content," which shows up as glitchy item animations and unnecessary rebinds on reorder.
#Compose: no ViewHolder, but the same instinct
LazyColumn doesn't pool an actual view object the way RecyclerView does, but it implements the same instinct through composition. Items scrolled off-screen aren't necessarily discarded outright — Compose keeps a small pool of previously-composed-but-now-offscreen node slots, and when a new item scrolls into view, it tries to reuse one of those slots (its layout nodes, its remembered internal state where the shape allows) instead of composing from nothing. Two parameters on items() decide how well that works: key, which gives each item a stable identity independent of its position so Compose can track "this is the same logical row" across a data change (missing it means Compose falls back to index-based identity, so inserting a row at the top makes every row below it look, to Compose, like it changed); and contentType, which — much like viewType in RecyclerView — segregates the reuse pool so a header's slot is never wastefully offered to a photo row's shape.
The other Compose-specific failure mode is over-broad recomposition scope. Every lambda passed to items { post -> ... } is its own restartable scope: reading post.likeCount inside that lambda means only that row recomposes when the like count changes. Reading list-wide derived state — like listState.firstVisibleItemIndex — directly inside a composable that also renders every row couples every row's recomposition to that value changing on every scroll pixel; wrapping it in derivedStateOf collapses "changes on every pixel" into "changes only when the derived boolean actually flips," and reading the derived value only where it's needed keeps the blast radius small. The same discipline that made onBindViewHolder fast — do the minimum work, scoped as narrowly as possible — applies here as "do the minimum recomposition, scoped as narrowly as possible."
class FeedDiffCallback : DiffUtil.ItemCallback<FeedPost>() {override fun areItemsTheSame(old: FeedPost, new: FeedPost) = old.id == new.idoverride fun areContentsTheSame(old: FeedPost, new: FeedPost) = old == new// A payload lets onBindViewHolder skip the expensive parts of a rebind.override fun getChangePayload(old: FeedPost, new: FeedPost): Any? =if (old.likeCount != new.likeCount && old.copy(likeCount = new.likeCount) == new) {LikeCountPayload(new.likeCount)} else null}class FeedAdapter : ListAdapter<FeedPost, FeedAdapter.PostViewHolder>(FeedDiffCallback()) {init { setHasStableIds(true) }override fun getItemId(position: Int): Long = getItem(position).idoverride fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder {val binding = ItemFeedPostBinding.inflate(LayoutInflater.from(parent.context), parent, false)return PostViewHolder(binding)}override fun onBindViewHolder(holder: PostViewHolder, position: Int) {holder.bind(getItem(position))}override fun onBindViewHolder(holder: PostViewHolder, position: Int, payloads: MutableList<Any>) {val likePayload = payloads.filterIsInstance<LikeCountPayload>().firstOrNull()if (likePayload != null) {holder.bindLikeCountOnly(likePayload.count) // skips re-decoding the row's image} else {super.onBindViewHolder(holder, position, payloads)}}class PostViewHolder(private val binding: ItemFeedPostBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(post: FeedPost) { /* full bind: image, author, caption, like count */ }fun bindLikeCountOnly(count: Int) {binding.likeCountText.text = count.toString()}}data class LikeCountPayload(val count: Int)}// A nested "stories" RecyclerView shares one pool across every row instance.val sharedStoriesPool = RecyclerView.RecycledViewPool()outerFeedRecyclerView.setRecycledViewPool(sharedStoriesPool)
@Composablefun FeedList(posts: List<FeedPost>, onLike: (Long) -> Unit) {val listState = rememberLazyListState()// isPastFold only changes — and only recomposes its readers — when the// boolean actually flips, not on every pixel of scroll.val isPastFold by remember {derivedStateOf { listState.firstVisibleItemIndex > 2 }}LazyColumn(state = listState) {items(items = posts,key = { post -> post.id }, // stable identity across data changescontentType = { post -> post.type }, // HEADER, IMAGE_POST, AD — mirrors viewType) { post ->FeedRow(post = post, onLike = onLike)}}if (isPastFold) {JumpToTopButton(onClick = { /* scroll to top */ })}}@Composableprivate fun FeedRow(post: FeedPost, onLike: (Long) -> Unit) {// Reading post.likeCount here scopes recomposition to THIS row only,// not to the whole LazyColumn, when a single post updates.Row(Modifier.fillMaxWidth().padding(12.dp)) {AsyncImage(model = post.thumbnailUrl, contentDescription = null)Text(text = "${post.likeCount} likes")}}