Optimizing RecyclerView vs Compose Lists
A side-by-side comparison of RecyclerView and LazyColumn performance characteristics, optimization strategies, and trade-offs for production Android apps.
Context
Lists are the most performance-sensitive UI component in most Android apps. Users spend the majority of their time scrolling through feeds, search results, chat messages, and settings. Two systems compete for this workload: RecyclerView from the View toolkit and LazyColumn/LazyRow from Jetpack Compose. Each has different performance characteristics, optimization levers, and failure modes.
Problem
Teams migrating from RecyclerView to LazyColumn often assume the same optimization techniques apply, or that Compose handles everything automatically. Neither is true. RecyclerView optimizes through view recycling and pre-fetching. LazyColumn optimizes through composition caching, key-based reuse, and skippable recomposition. Misunderstanding these differences leads to scroll jank in production.
Constraints
- RecyclerView recycles
Viewobjects (heavyweight, inflate once, rebind many times) - LazyColumn recycles compositions (lightweight, but recomposition is the cost center)
- RecyclerView supports
DiffUtilfor efficient list updates. LazyColumn relies on key-based identity - RecyclerView can nest heterogeneous view types efficiently. LazyColumn uses
contentType - Both systems pre-fetch off-screen items, but through different mechanisms
- View-based inflation is expensive. Compose composition is cheap but recomposition can be expensive if stability is violated
Design
Architecture Comparison
| Aspect | RecyclerView | LazyColumn |
|---|---|---|
| Recycling unit | View (XML layout or programmatic) | Composition (slot table entries) |
| Item identity | Position-based, with optional stable IDs | Key-based (key parameter) |
| Update mechanism | DiffUtil + ListAdapter | Snapshot-based recomposition |
| View type system | getItemViewType() returns int | contentType parameter |
| Pre-fetch | GapWorker pre-fetches on RenderThread idle | Composition pre-fetch on idle |
| Nested scrolling | ConcatAdapter, NestedScrollView | LazyColumn with mixed item/items |
| Item animations | ItemAnimator | animateItem() modifier |
RecyclerView Optimization Playbook
1. Use ListAdapter with DiffUtil
DiffUtil calculates the minimal set of changes between old and new lists, enabling granular animations and avoiding full rebinds.
class FeedAdapter : ListAdapter<FeedItem, FeedViewHolder>(FeedDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeedViewHolder {
val binding = ItemFeedBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return FeedViewHolder(binding)
}
override fun onBindViewHolder(holder: FeedViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun getItemViewType(position: Int): Int = when (getItem(position)) {
is FeedItem.Post -> VIEW_TYPE_POST
is FeedItem.Ad -> VIEW_TYPE_AD
is FeedItem.Story -> VIEW_TYPE_STORY
}
}
class FeedDiffCallback : DiffUtil.ItemCallback<FeedItem>() {
override fun areItemsTheSame(old: FeedItem, new: FeedItem): Boolean = old.id == new.id
override fun areContentsTheSame(old: FeedItem, new: FeedItem): Boolean = old == new
}2. Pre-compute Layout with setHasFixedSize
If all items have the same height, tell RecyclerView:
recyclerView.setHasFixedSize(true) // Skips requestLayout on adapter changes3. Optimize onBindViewHolder
The bind method runs on the main thread for every item that scrolls into view. Keep it allocation-free and computation-free.
class FeedViewHolder(private val binding: ItemFeedBinding) :
RecyclerView.ViewHolder(binding.root) {
// Store click target to avoid lambda allocation per bind
private var currentItem: FeedItem? = null
init {
binding.root.setOnClickListener { currentItem?.let(onItemClick) }
}
fun bind(item: FeedItem) {
currentItem = item
binding.title.text = item.title
binding.subtitle.text = item.subtitle
// Avoid: Glide.with().load().into() if image hasn't changed
if (binding.image.tag != item.imageUrl) {
binding.image.tag = item.imageUrl
Glide.with(binding.image).load(item.imageUrl).into(binding.image)
}
}
}4. RecycledViewPool for Nested RecyclerViews
When RecyclerViews are nested (e.g., horizontal carousels inside a vertical list), share a RecycledViewPool to avoid inflating the same view types multiple times.
val sharedPool = RecyclerView.RecycledViewPool()
class OuterAdapter : RecyclerView.Adapter<OuterViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OuterViewHolder {
val innerRecyclerView = RecyclerView(parent.context)
innerRecyclerView.setRecycledViewPool(sharedPool)
return OuterViewHolder(innerRecyclerView)
}
}LazyColumn Optimization Playbook
1. Stable Keys
Keys tell LazyColumn which items are the same across updates. Without keys, position-based identity causes unnecessary recomposition when items are inserted or removed.
LazyColumn {
items(
items = feedItems,
key = { it.id } // Stable identity
) { item ->
FeedItemRow(item)
}
}2. Content Types
Content types enable composition reuse across different item types. A Post composition can be recycled for another Post but not for an Ad.
LazyColumn {
items(
items = feedItems,
key = { it.id },
contentType = { item ->
when (item) {
is FeedItem.Post -> "post"
is FeedItem.Ad -> "ad"
is FeedItem.Story -> "story"
}
}
) { item ->
when (item) {
is FeedItem.Post -> PostCard(item)
is FeedItem.Ad -> AdCard(item)
is FeedItem.Story -> StoryRow(item)
}
}
}3. Stable Item Parameters
Each item composable must have stable parameters to be skippable. If FeedItem is unstable, every visible item recomposes on any list state change.
// Unstable: List field
data class FeedItem(
val id: String,
val title: String,
val tags: List<String> // Unstable
)
// Stable
@Immutable
data class FeedItem(
val id: String,
val title: String,
val tags: ImmutableList<String>
)4. Avoid State Reads Above LazyColumn
// Bad: searchQuery state change recomposes LazyColumn and all visible items
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val searchQuery by viewModel.searchQuery.collectAsState()
val items by viewModel.items.collectAsState()
Column {
SearchBar(query = searchQuery, onQueryChange = viewModel::onQueryChange)
LazyColumn { // Recomposes when searchQuery changes
items(items, key = { it.id }) { FeedItemRow(it) }
}
}
}
// Good: isolate searchQuery reads
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val items by viewModel.items.collectAsState()
Column {
SearchBarWrapper(viewModel) // searchQuery read is inside here
LazyColumn {
items(items, key = { it.id }) { FeedItemRow(it) }
}
}
}
@Composable
fun SearchBarWrapper(viewModel: FeedViewModel) {
val searchQuery by viewModel.searchQuery.collectAsState()
SearchBar(query = searchQuery, onQueryChange = viewModel::onQueryChange)
}Head-to-Head Performance
| Metric | RecyclerView (Optimized) | LazyColumn (Optimized) |
|---|---|---|
| Item inflation/composition cost | High (XML inflate), but amortized by recycling | Low (composition), but recomposition cost per update |
| Scroll smoothness (P95 frame time) | Excellent with DiffUtil + fixed size | Excellent with stable keys + stable params |
| Memory per off-screen item | Full View tree retained in pool | Composition state retained, views not |
| Heterogeneous item types | Efficient with view type system | Efficient with contentType |
| Nested lists | SharedPool optimization available | No equivalent, but generally lighter |
| Item animations | ItemAnimator (battle-tested) | animateItem (improving but newer) |
Trade-offs
Related: Memory Leaks in Android: Patterns I've Seen in Production.
See also: Memory Allocation Patterns That Hurt Performance.
| Choice | Gain | Cost |
|---|---|---|
| RecyclerView for existing View code | Mature, well-understood, proven at scale | Boilerplate (Adapter, ViewHolder, DiffUtil) |
| LazyColumn for new Compose code | Declarative, less boilerplate, integrated with Compose state | Requires stability discipline, newer animation system |
| ComposeView inside RecyclerView items | Incremental migration | Each item hosts a Compose tree, higher overhead than pure View or pure Compose |
| AndroidView inside LazyColumn | Use existing custom Views in Compose | View inflation cost per item, interop overhead |
Failure Modes
| Failure | System | Symptom | Fix |
|---|---|---|---|
| Missing DiffUtil | RecyclerView | Full list rebind on every update | Use ListAdapter |
| Missing keys | LazyColumn | Items shuffle on insert/delete | Add key parameter |
| Unstable item params | LazyColumn | All visible items recompose on any change | Use @Immutable data classes |
| Missing view type | RecyclerView | Wrong view recycled, visual glitches | Implement getItemViewType() |
| Missing contentType | LazyColumn | Composition cache misses, slower scroll | Add contentType parameter |
| Image re-loading on rebind | Both | Flicker during fast scroll | Check cache before loading |
Scaling Considerations
- 10,000+ items: both systems handle this well with proper optimization. RecyclerView with
AsyncListDifferand LazyColumn with Paging 3collectAsLazyPagingItems() - Complex item layouts: RecyclerView benefits from flat ConstraintLayout items. LazyColumn benefits from simple composable trees without intrinsic measurements
- Mixed View/Compose codebases: avoid
ComposeViewinside RecyclerView items at scale. The interop overhead per item is measurable. Migrate entire lists, not individual items - Accessibility: both systems support content descriptions and traversal. Test with TalkBack for both implementations
Observability
- RecyclerView:
RecyclerView.getRecycledViewPool().getRecycledViewCount(viewType)shows pool utilization - LazyColumn: Layout Inspector shows recomposition count per item
- Both: Perfetto frame timing during scroll, JankStats per-screen jank rate
- Production metrics: track scroll-correlated jank rate and P95 frame duration during list interactions
Key Takeaways
- RecyclerView and LazyColumn optimize through fundamentally different mechanisms. Do not apply one system's patterns to the other
- For RecyclerView: DiffUtil, fixed size, allocation-free bind, shared RecycledViewPool
- For LazyColumn: stable keys, contentType, stable/immutable item data classes, scoped state reads
- Do not mix interop (ComposeView in RecyclerView, AndroidView in LazyColumn) at item-level scale. Migrate entire lists
- Measure scroll performance on low-end devices. Both systems can deliver 60fps, but only with deliberate optimization
Further Reading
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
- Reducing APK Size Without Breaking Features: Practical techniques for shrinking Android APK size in production apps, covering R8 configuration, resource optimization, native library ...
- How Garbage Collection Impacts Android Performance: A detailed look at ART's garbage collection mechanisms, how GC pauses affect frame rates, and practical strategies to minimize GC impact ...
Final Thoughts
The choice between RecyclerView and LazyColumn for a given screen should be driven by the surrounding codebase (View vs Compose), not by performance assumptions. Both achieve the same performance ceiling when optimized correctly. The optimization strategies are different: RecyclerView demands disciplined adapter and ViewHolder patterns, while LazyColumn demands stability and scoping discipline. Know both playbooks and apply the right one.
Recommended
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.