Optimizing RecyclerView vs Compose Lists

Dhruval Dhameliya·November 23, 2025·8 min read

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 View objects (heavyweight, inflate once, rebind many times)
  • LazyColumn recycles compositions (lightweight, but recomposition is the cost center)
  • RecyclerView supports DiffUtil for 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

AspectRecyclerViewLazyColumn
Recycling unitView (XML layout or programmatic)Composition (slot table entries)
Item identityPosition-based, with optional stable IDsKey-based (key parameter)
Update mechanismDiffUtil + ListAdapterSnapshot-based recomposition
View type systemgetItemViewType() returns intcontentType parameter
Pre-fetchGapWorker pre-fetches on RenderThread idleComposition pre-fetch on idle
Nested scrollingConcatAdapter, NestedScrollViewLazyColumn with mixed item/items
Item animationsItemAnimatoranimateItem() 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 changes

3. 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

MetricRecyclerView (Optimized)LazyColumn (Optimized)
Item inflation/composition costHigh (XML inflate), but amortized by recyclingLow (composition), but recomposition cost per update
Scroll smoothness (P95 frame time)Excellent with DiffUtil + fixed sizeExcellent with stable keys + stable params
Memory per off-screen itemFull View tree retained in poolComposition state retained, views not
Heterogeneous item typesEfficient with view type systemEfficient with contentType
Nested listsSharedPool optimization availableNo equivalent, but generally lighter
Item animationsItemAnimator (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.

ChoiceGainCost
RecyclerView for existing View codeMature, well-understood, proven at scaleBoilerplate (Adapter, ViewHolder, DiffUtil)
LazyColumn for new Compose codeDeclarative, less boilerplate, integrated with Compose stateRequires stability discipline, newer animation system
ComposeView inside RecyclerView itemsIncremental migrationEach item hosts a Compose tree, higher overhead than pure View or pure Compose
AndroidView inside LazyColumnUse existing custom Views in ComposeView inflation cost per item, interop overhead

Failure Modes

FailureSystemSymptomFix
Missing DiffUtilRecyclerViewFull list rebind on every updateUse ListAdapter
Missing keysLazyColumnItems shuffle on insert/deleteAdd key parameter
Unstable item paramsLazyColumnAll visible items recompose on any changeUse @Immutable data classes
Missing view typeRecyclerViewWrong view recycled, visual glitchesImplement getItemViewType()
Missing contentTypeLazyColumnComposition cache misses, slower scrollAdd contentType parameter
Image re-loading on rebindBothFlicker during fast scrollCheck cache before loading

Scaling Considerations

  • 10,000+ items: both systems handle this well with proper optimization. RecyclerView with AsyncListDiffer and LazyColumn with Paging 3 collectAsLazyPagingItems()
  • Complex item layouts: RecyclerView benefits from flat ConstraintLayout items. LazyColumn benefits from simple composable trees without intrinsic measurements
  • Mixed View/Compose codebases: avoid ComposeView inside 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

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