โ™ป๏ธ Core Concept ยท ~35 min read ยท Intermediate โ†’ Advanced

RecyclerView

How Android efficiently renders thousands of items using a pool of reusable views โ€” and why understanding the recycling pipeline, DiffUtil, and the ViewHolder contract is non-negotiable for any Android interview.

Why RecyclerView exists

Before RecyclerView, the go-to for lists was ListView. ListView worked โ€” but it had a fundamental performance problem. By default, it created a new View for every item in the list. Scroll through a list of 1,000 items and ListView would try to inflate 1,000 views. On a slow device with complex list item layouts, this was catastrophic: dropped frames, janky scrolling, and out-of-memory crashes on large datasets.

The ViewHolder pattern was retrofitted onto ListView as a workaround โ€” developers could optionally cache the view reference in convertView.tag to avoid re-inflating. But it was optional, easy to get wrong, and still didn't solve the deeper problem: ListView was tightly coupled to its layout strategy, its animation system, and its adapter. You couldn't use a grid layout, add custom item animations, or compose multiple data sources without significant gymnastics.

RecyclerView arrived in 2014 and rethought the entire system from the ground up. It separated concerns cleanly: the LayoutManager decides where items go, the Adapter creates and binds views, the ViewHolder holds view references, the ItemAnimator handles transitions, and the RecycledViewPool manages the reuse pool. Each piece is swappable. The recycling is mandatory, not optional. And crucially, RecyclerView only ever creates as many views as fit on screen โ€” plus a small buffer. All the rest are recycled and reused.

โ™ป๏ธ Analogy

Think of a restaurant with 200 customers on a waiting list. You don't buy 200 tables. You have 10 tables. When a party of 4 finishes and leaves, you clean the table and seat the next party. RecyclerView works exactly like this โ€” a fixed pool of "tables" (ViewHolders) that get cleaned and reassigned as items scroll off and on screen.

The RecyclerView architecture

RecyclerView is not one class doing everything โ€” it's a collaboration between five components, each with a single responsibility.

๐Ÿ“‹ RecyclerView architecture โ€” five components, one pipeline
RecyclerView the orchestrator LayoutManager Decides where each item is placed on screen Adapter Creates ViewHolders, binds data to them ViewHolder Caches view references. The unit of recycling. ItemAnimator Animates add/remove/ move/change events RecycledViewPool Stores detached ViewHolders for reuse across scrolls ItemDecoration Draws dividers, spacing, badges

How recycling actually works

The recycling pipeline is the heart of RecyclerView. Understanding it precisely โ€” not just "views get reused" โ€” is what separates a strong interview answer from a weak one. There are actually four pools involved, accessed in order:

๐Ÿ“‹ The four recycling pools โ€” checked in order on every item request
LayoutManager needs a ViewHolder for position N โ†’ โ‘  Scrap Still attached views being relaid out. No rebind needed. โšก miss โ‘ก Cache Recently detached views (default size: 2). No rebind needed. โšก miss โ‘ข ViewCache Extension Custom pool you provide. Rarely used. miss โ‘ฃ RecycledViewPool Type-bucketed pool of detached ViewHolders. Must rebind. ๐Ÿ”„ miss createViewHolder() bindViewHolder() ViewHolder returned to LayoutManager โ†’ placed on screen โšก Pools โ‘  โ‘ก never call onBindViewHolder โ€” fastest path. Pool โ‘ฃ always rebinds. Scrolling fast? Mostly pool โ‘ฃ. Relayout (e.g. DiffUtil)? Mostly pool โ‘ .

What happens step-by-step during a scroll

Let's walk through exactly what RecyclerView does as the user scrolls down and three new items come into view while three scroll off the top.

Step 1 โ€” LayoutManager requests a ViewHolder. As an item enters the viewport, the LayoutManager calls Recycler.getViewForPosition(position). The Recycler is RecyclerView's internal broker โ€” it runs the four-pool lookup on every request.

Step 2 โ€” Check attached scrap first. The Scrap pool is actually split into two: attached scrap (views still connected to RecyclerView but being relaid out, e.g. during a DiffUtil dispatch) and changed scrap (views marked as changed via notifyItemChanged). If the exact position is in either scrap, it's returned immediately with no rebind โ€” this is the fastest path possible, used heavily during predictive animations.

Step 3 โ€” Check the Cache. The cache (size 2 by default, tunable via setItemViewCacheSize()) holds recently-detached ViewHolders keyed by position. If the user scrolled down and then immediately back up, the just-departed views are here โ€” same position, same data, no rebind. This is what makes small back-scrolls feel instant.

Step 4 โ€” Check ViewCacheExtension. Rarely used. Skipped for most adapters.

Step 5 โ€” Check RecycledViewPool. Views here are keyed only by view type, not position. A product card ViewHolder is indistinguishable from any other product card ViewHolder in this pool โ€” they're all in the same bucket. The Recycler grabs one, calls onBindViewHolder(holder, position) to stamp fresh data onto it, and returns it. This is the normal recycling path during continuous scrolling.

Step 6 โ€” Last resort: createViewHolder. If all pools are empty (typically only during the initial fill when the list first appears), onCreateViewHolder is called. Layout inflation happens here โ€” this is the expensive part. Once enough ViewHolders exist to fill the screen, this almost never runs again during normal scrolling.

๐Ÿ” Two types of scrap โ€” why it matters

The difference between attached scrap and changed scrap matters for animations. When you call notifyItemChanged(5), item 5 goes into changed scrap (flagged as needing rebind) while its replacement is fetched from the pool and laid out. This lets ItemAnimator run a crossfade between the old and new state โ€” the old view fades out while the new one fades in. If RecyclerView only had one scrap, this animation couldn't happen because the old view would be gone before the animation could start.

The takeaway from the four pools: Scrap and Cache are the fast paths โ€” no rebind required, the ViewHolder still holds the correct data. The RecycledViewPool is the rebind path โ€” the ViewHolder is type-correct but its data is stale, so onBindViewHolder() must be called to populate it with new data. Creating a new ViewHolder via createViewHolder() is always the last resort and most expensive option.

The ViewHolder pattern

A ViewHolder has one job: hold references to the views inside a single list item so you never call findViewById() during scrolling. findViewById() walks the entire view tree โ€” it's an O(n) operation on the tree depth. Calling it inside onBindViewHolder() (which runs on every scroll) is the single easiest way to make a list janky. The ViewHolder caches those references once at inflation time, and every subsequent bind reuses them.

ProductAdapter.kt โ€” a complete, production-quality adapter
class ProductAdapter( private val onItemClick: (Product) -> Unit, private val onWishlistClick: (Product) -> Unit ) : ListAdapter<Product, ProductAdapter.ViewHolder>(ProductDiffCallback()) { // โ”€โ”€ ViewHolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ inner class ViewHolder(private val binding: ItemProductBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(product: Product) { binding.apply { tvName.text = product.name tvPrice.text = "โ‚น${product.price}" ivThumbnail.load(product.imageUrl) // Coil/Glide btnWishlist.isSelected = product.isWishlisted root.setOnClickListener { onItemClick(product) } btnWishlist.setOnClickListener { onWishlistClick(product) } } } // Partial bind โ€” only update fields that changed fun bindWishlist(isWishlisted: Boolean) { binding.btnWishlist.isSelected = isWishlisted } } // โ”€โ”€ Adapter overrides โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { // Inflate once โ€” this is the expensive call. Called rarely. val binding = ItemProductBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { // Called on every scroll โ€” must be fast. Only set data, no inflation. holder.bind(getItem(position)) } // Partial bind using payloads โ€” skip full rebind for tiny changes override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) { if (payloads.isEmpty()) { super.onBindViewHolder(holder, position, payloads) } else { // Only the wishlist state changed โ€” update just that view (payloads[0] as? Boolean)?.let { holder.bindWishlist(it) } } } }

ViewHolder lifecycle callbacks

Beyond onCreateViewHolder and onBindViewHolder, the adapter has four more callbacks that most developers never use โ€” but they matter for correctness, especially when your ViewHolders hold resources like media players, timers, or registered listeners.

CallbackWhen it firesUse it to
onCreateViewHolder View is inflated for the first time Inflate layout, cache references, set static click listeners
onBindViewHolder ViewHolder is about to be displayed with new data Set text, images, dynamic click listeners โ€” keep it fast
onViewAttachedToWindow View is attached to RecyclerView's window (visible on screen) Start animations, begin video playback, register time-sensitive listeners
onViewDetachedFromWindow View scrolls off screen (but may return from cache) Pause animations, stop video playback, cancel countdown timers
onViewRecycled ViewHolder enters the RecycledViewPool Clear images (cancel Glide/Coil requests), unregister listeners, release heavy resources โ€” the view will be rebound to different data
onFailedToRecycleView RecyclerView tried to recycle the view but it has an active animation or transient state Return true to force recycling anyway (rarely needed)
โœ… The most important one: onViewRecycled

If your list items load images with Glide or Coil, always clear the request in onViewRecycled. Without this, a recycled ViewHolder might have a stale Glide request finishing and overwriting the new image that onBindViewHolder just started loading โ€” causing a flicker or completely wrong image. The fix:

Adapter โ€” cleaning up in onViewRecycled
override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) // Cancel any in-flight image request โ€” the view is going to new data holder.binding.ivThumbnail.dispose() // Coil // Glide.with(holder.itemView).clear(holder.binding.ivThumbnail) } override fun onViewAttachedToWindow(holder: ViewHolder) { super.onViewAttachedToWindow(holder) holder.startAnimation() // e.g. shimmer, lottie, auto-play video } override fun onViewDetachedFromWindow(holder: ViewHolder) { super.onViewDetachedFromWindow(holder) holder.stopAnimation() // pause before it hits the pool }

The ordering of these calls during a typical scroll looks like this: onCreateViewHolder โ†’ onBindViewHolder โ†’ onViewAttachedToWindow โ€ฆ (user scrolls) โ€ฆ onViewDetachedFromWindow โ†’ onViewRecycled (if it exits cache). If the item comes back from the cache without being recycled, it goes: onViewAttachedToWindow directly โ€” no rebind. This is why the Cache is so fast.

DiffUtil โ€” the engine behind smooth list updates

The naive way to update a list is notifyDataSetChanged() โ€” tell RecyclerView "everything changed, rebind everything." It works, but it's wasteful and produces terrible UX: the entire list flickers, scroll position resets, and no item animations play. If you changed one item out of 100, you just forced 100 rebinds and killed every transition animation.

DiffUtil solves this by computing the minimal set of changes between two lists using the Myers diff algorithm โ€” the same algorithm git uses for file diffs. You give it the old list and the new list, it figures out exactly which items were added, removed, moved, or changed, and dispatches fine-grained notifications to the adapter. Only the changed items rebind. Animations play correctly. Scroll position is preserved.

The two-pass process: identity vs content

DiffUtil calls your callback in two distinct passes, and understanding both is essential for writing a correct callback.

Pass 1 โ€” areItemsTheSame(). DiffUtil first asks: "are these the same logical item?" This is an identity check โ€” not about what the item looks like, but whether it represents the same real-world entity. For a product, this means comparing stable IDs (old.id == new.id). If this returns true, DiffUtil treats them as the same item and moves to pass 2. If it returns false, DiffUtil treats one as a removal and the other as an insertion. Never compare by content here โ€” if a product's price changes from โ‚น100 to โ‚น150, it's still the same product. Comparing by price would incorrectly produce a remove + insert animation instead of a change animation.

Pass 2 โ€” areContentsTheSame(). Only called when areItemsTheSame() returned true. DiffUtil now asks: "does the item look different?" This is a content check โ€” compare every field the user can see. With Kotlin data classes, old == new does structural equality automatically and works perfectly here. If this returns false, DiffUtil dispatches notifyItemChanged(position) โ€” only that one item rebinds and animates. If it returns true, nothing happens at all for that item.

Optional pass โ€” getChangePayload(). When areContentsTheSame() returns false, DiffUtil calls this optional method. It lets you return a hint about what specifically changed โ€” e.g. only the wishlist state. The adapter receives this as the payloads parameter in onBindViewHolder(holder, position, payloads). If payloads is non-empty, you can update only the changed view instead of the entire row โ€” avoiding flicker on everything else.

๐Ÿ’ก Why ListAdapter is the right default

Calling DiffUtil.calculateDiff() manually is synchronous and blocks the main thread for large lists (it's O(N) space and O(N + Dยฒ) time where D is the edit distance). ListAdapter solves this by running the diff on a background thread and posting the result back to the main thread automatically. You just call submitList(newList) โ€” no AsyncListDiffer boilerplate, no thread management. For lists under ~1000 items the difference is imperceptible, but it's a better habit regardless.

๐Ÿ“‹ DiffUtil โ€” computing the minimal diff between two lists
Old List A (id=1, price=100) B (id=2, price=200) C (id=3, price=300) D (id=4, price=400) DiffUtil Myers diff algorithm areItemsTheSame() areContentsTheSame() New List A (id=1, price=100) B (id=2, price=250) โœ๏ธ C removed โœ• D (id=4, price=400) E (id=5) inserted โœš Dispatches A โ€” no change B โ€” notifyChanged C โ€” notifyRemoved D โ€” no change E โ€” notifyInserted Only 3 operations instead of rebinding all 5 items. Animations play correctly.
DiffUtil.ItemCallback โ€” the two questions DiffUtil asks
class ProductDiffCallback : DiffUtil.ItemCallback<Product>() { // "Is this the same logical item?" โ€” compare stable IDs, not content // If true, DiffUtil checks areContentsTheSame next override fun areItemsTheSame(old: Product, new: Product): Boolean = old.id == new.id // "Does the item look different?" โ€” compare all visible fields // If false, notifyItemChanged is dispatched (triggers onBindViewHolder) override fun areContentsTheSame(old: Product, new: Product): Boolean = old == new // works perfectly with data classes (structural equality) // Optional: return the specific change as a payload for partial binding override fun getChangePayload(old: Product, new: Product): Any? { // Return just the changed field โ€” adapter uses it in onBindViewHolder(payloads) return if (old.isWishlisted != new.isWishlisted) new.isWishlisted else null } } // ListAdapter wraps DiffUtil โ€” no manual submitList boilerplate // It runs DiffUtil on a background thread automatically vm.products.observe(viewLifecycleOwner) { adapter.submitList(it) }
โš ๏ธ The Mutable List Trap

Never pass the same mutable list to submitList() after modifying it in place. DiffUtil compares the new list to its internal copy โ€” if they're the same object, it sees no differences and does nothing. Always pass a new list: adapter.submitList(items.toList()) or build a new list from scratch. This is by far the most common DiffUtil bug.

LayoutManagers

The LayoutManager is fully responsible for positioning items and deciding the scrolling direction. RecyclerView ships with three built-in options, and you can write your own if needed.

LayoutManagerUse forKey params
LinearLayoutManager Vertical or horizontal lists, chat screens, feeds orientation, reverseLayout (chat UIs)
GridLayoutManager Product grids, image galleries, app drawers spanCount, SpanSizeLookup for variable span
StaggeredGridLayoutManager Pinterest-style masonry grids, mixed-height items spanCount, orientation, gapStrategy
LayoutManager tips โ€” spanning headers, reverse chat, variable columns
// Grid with a full-width header (span=1 for items, span=3 for header) val gridLM = GridLayoutManager(context, 3) gridLM.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int = if (adapter.getItemViewType(position) == TYPE_HEADER) 3 else 1 } // Reverse layout โ€” new messages appear at bottom (chat UI) val chatLM = LinearLayoutManager(context).apply { reverseLayout = true stackFromEnd = true } // Responsive columns โ€” different counts for phone vs tablet val spanCount = if (resources.configuration.screenWidthDp >= 600) 3 else 2 recyclerView.layoutManager = GridLayoutManager(context, spanCount)

Multiple view types

Most production lists have more than one type of item โ€” headers, ads, loading placeholders, banners mixed with regular items. RecyclerView handles this with getItemViewType(). Each unique type gets its own pool bucket in the RecycledViewPool โ€” a ViewHolder for a header will never be recycled into a product card slot.

Multi-type adapter โ€” headers, products, and a loading footer
class FeedAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { const val TYPE_HEADER = 0 const val TYPE_PRODUCT = 1 const val TYPE_LOADING = 2 } override fun getItemViewType(position: Int): Int = when { position == 0 -> TYPE_HEADER position == itemCount - 1 -> TYPE_LOADING else -> TYPE_PRODUCT } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { TYPE_HEADER -> HeaderViewHolder(/* inflate header */) TYPE_PRODUCT -> ProductViewHolder(/* inflate product */) else -> LoadingViewHolder(/* inflate loading */) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is ProductViewHolder -> holder.bind(products[position - 1]) is HeaderViewHolder -> holder.bind(headerText) is LoadingViewHolder -> /* nothing to bind */ } } }

ConcatAdapter โ€” composing adapters together

Writing a single adapter that handles headers, footers, loading states, and content is messy. ConcatAdapter (introduced in RecyclerView 1.2) lets you compose multiple independent adapters in sequence. A header adapter, a content adapter, and a footer adapter each handle their own type โ€” ConcatAdapter chains them together. Each adapter stays focused and independently testable.

ConcatAdapter โ€” chain independent adapters into one list
val headerAdapter = BannerAdapter() val productsAdapter = ProductAdapter(onItemClick = { /* ... */ }) val footerAdapter = LoadingFooterAdapter() // Chain them โ€” RecyclerView sees one unified list recyclerView.adapter = ConcatAdapter(headerAdapter, productsAdapter, footerAdapter) // Each adapter manages its own items independently vm.products.observe(viewLifecycleOwner) { productsAdapter.submitList(it) } vm.isLoading.observe(viewLifecycleOwner) { footerAdapter.setLoading(it) } // Stable IDs across adapters: use ConcatAdapter.Config val config = ConcatAdapter.Config.Builder() .setIsolateViewTypes(false) // allows ViewHolder reuse across adapters .build() recyclerView.adapter = ConcatAdapter(config, headerAdapter, productsAdapter, footerAdapter)

ItemDecoration โ€” dividers, spacing, and badges

ItemDecoration lets you draw on top of or around items โ€” dividers, spacing between items, section badges, selection highlights. The key advantage over adding padding in item layouts: ItemDecoration is applied by RecyclerView itself and knows about item positions, so you can easily skip the divider after the last item, or only draw a badge on items of a certain type.

GridSpacingDecoration โ€” equal spacing for grid items
class GridSpacingDecoration(private val spanCount: Int, private val spacing: Int) : RecyclerView.ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { val position = parent.getChildAdapterPosition(view) val column = position % spanCount outRect.left = column * spacing / spanCount outRect.right = spacing - (column + 1) * spacing / spanCount if (position >= spanCount) outRect.top = spacing // no top for first row } } // Usage recyclerView.addItemDecoration(GridSpacingDecoration(spanCount = 2, spacing = 16.dp))

Performance โ€” keeping scroll at 60fps

RecyclerView is fast by default, but there are several high-impact optimisations every production list should use.

Performance optimisations โ€” the full checklist
// 1. setHasFixedSize โ€” skip re-measuring RecyclerView when adapter changes // Use when the RV's own size doesn't change when items are added/removed recyclerView.setHasFixedSize(true) // 2. setItemViewCacheSize โ€” increase the off-screen cache (default: 2) // More cached ViewHolders = fewer rebinds when scrolling back up slightly recyclerView.setItemViewCacheSize(10) // 3. Shared RecycledViewPool โ€” multiple RecyclerViews share one pool // Great for ViewPager2 where each tab has a list of the same type val sharedPool = RecyclerView.RecycledViewPool() sharedPool.setMaxRecycledViews(TYPE_PRODUCT, 20) // default is 5 per type tab1RecyclerView.setRecycledViewPool(sharedPool) tab2RecyclerView.setRecycledViewPool(sharedPool) // 4. Prefetch โ€” LinearLayoutManager pre-creates ViewHolders during idle time // Enabled by default on API 21+. Control with: (recyclerView.layoutManager as LinearLayoutManager).initialPrefetchItemCount = 4 // 5. Avoid nested RecyclerViews scrolling in the same direction // Each nested RV has its own pool โ€” ViewHolders can't be shared // Use ConcatAdapter instead for vertical sections // 6. setHasStableIds โ€” allows RecyclerView to reuse ViewHolders more aggressively // ONLY safe if your IDs are truly stable and unique across the entire dataset adapter.setHasStableIds(true) override fun getItemId(position: Int): Long = products[position].id.hashCode().toLong() // 7. Heavy work off the main thread โ€” decode images in Coil/Glide, not in bind // 8. Use payloads in DiffUtil to avoid full rebinds for single-field changes

Paging 3 integration

When your list has thousands of items from a server, you can't load them all upfront. Paging 3 handles this transparently: it loads pages of data on demand as the user scrolls, exposes the result as a Flow<PagingData<T>> from the ViewModel, and a PagingDataAdapter (which extends ListAdapter) consumes it. RecyclerView never knows โ€” from its perspective it's just a regular adapter. Paging 3 handles retry logic, load state, and error states out of the box.

Paging 3 โ€” wiring PagingSource to PagingDataAdapter
// 1. PagingSource โ€” loads one page from network/DB class ProductPagingSource(private val api: ProductApiService) : PagingSource<Int, Product>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> { val page = params.key ?: 1 return try { val response = api.getProducts(page = page, size = params.loadSize) LoadResult.Page( data = response.items, prevKey = if (page == 1) null else page - 1, nextKey = if (response.isLastPage) null else page + 1 ) } catch (e: Exception) { LoadResult.Error(e) } } override fun getRefreshKey(state: PagingState<Int, Product>) = state.anchorPosition } // 2. ViewModel โ€” exposes paged Flow, cached to survive rotation @HiltViewModel class ProductListViewModel @Inject constructor(private val api: ProductApiService) : ViewModel() { val products = Pager( config = PagingConfig(pageSize = 20, prefetchDistance = 5) ) { ProductPagingSource(api) }.flow.cachedIn(viewModelScope) } // 3. PagingDataAdapter โ€” just like ListAdapter, uses DiffUtil automatically class ProductPagingAdapter : PagingDataAdapter<Product, ProductAdapter.ViewHolder>(ProductDiffCallback()) { override fun onCreateViewHolder(/* ... */) = /* same as before */ override fun onBindViewHolder(holder: ViewHolder, position: Int) { getItem(position)?.let { holder.bind(it) } // getItem may be null (placeholder) } } // 4. Fragment โ€” collect and submit, handle load states lifecycleScope.launch { vm.products.collectLatest { adapter.submitData(it) } } adapter.loadStateFlow.collect { states -> binding.progressBar.isVisible = states.refresh is LoadState.Loading binding.retryButton.isVisible = states.refresh is LoadState.Error }

Common mistakes

Calling notifyDataSetChanged()

This is the nuclear option โ€” it tells RecyclerView "assume everything is different, rebind everything, don't animate anything." It kills animations, resets scroll momentum, and does far more work than needed. 99% of the time you should be using ListAdapter with submitList() and letting DiffUtil dispatch precise notifications. Reserve notifyDataSetChanged() only when you genuinely can't compute a diff โ€” like when the entire dataset's sort order has changed.

Setting click listeners in onCreateViewHolder

Click listeners set in onCreateViewHolder capture the ViewHolder's position at creation time. When items are inserted or deleted, that position becomes stale. Always set click listeners in onBindViewHolder (or, even better, use holder.adapterPosition / holder.bindingAdapterPosition at click time). The cleanest pattern โ€” used in the adapter above โ€” is passing lambdas to ViewHolder.bind() which capture the current item directly.

Heavy work in onBindViewHolder

onBindViewHolder runs on the main thread and is called on every visible item during scroll. Any operation that takes more than a couple of milliseconds here will drop frames. Common culprits: synchronous image decoding, complex date/string formatting computed inline, view hierarchy inflation, or notifyDataSetChanged() called from inside a bind. Format strings ahead of time in the ViewModel or data class; decode images with Glide/Coil; never inflate from onBindViewHolder.

How this connects to system design interviews

RecyclerView comes up in nearly every Android system design question โ€” feeds, search results, order history, chat. The depth of your answer matters more than the fact that you know RecyclerView exists. Strong answers go beyond "use RecyclerView with an adapter" and address specifics: "I'd use a PagingDataAdapter with a RemoteMediator to cache pages in Room so the feed works offline. The ViewModel exposes Flow<PagingData> cached with cachedIn(viewModelScope). DiffUtil handles all item change animations automatically. For the mixed content types โ€” banner, product card, loading footer โ€” I'd use ConcatAdapter so each type is managed independently."

Performance questions are equally common: "How do you keep a list with images smooth?" The answer hits prefetching, shared RecycledViewPool for tabbed lists, setHasFixedSize(true), off-thread image decoding via Coil/Glide, DiffUtil payloads for partial rebinds, and avoiding nested RecyclerViews in the same scroll direction.

๐ŸŽฏ Interview Summary

Four pools โ€” Scrap (no rebind) โ†’ Cache (no rebind) โ†’ ViewCacheExtension โ†’ RecycledViewPool (rebinds). Creating new is last resort.
onCreateViewHolder โ€” inflates layout, called rarely. onBindViewHolder โ€” binds data, called on every scroll item, must be fast.
DiffUtil โ€” Myers diff between old/new lists. areItemsTheSame = same logical item (compare IDs). areContentsTheSame = visually identical (compare content).
ListAdapter โ€” runs DiffUtil on a background thread automatically. Always use over plain Adapter.
Payloads โ€” getChangePayload() returns partial change; adapter skips full rebind.
ConcatAdapter โ€” compose header/content/footer adapters independently. Prefer over multi-type adapter.
setHasFixedSize(true) โ€” skip RecyclerView re-measure on adapter changes.
Shared RecycledViewPool โ€” across ViewPager2 tabs of the same item type.
Paging 3 โ€” PagingSource + Pager + PagingDataAdapter. cachedIn(viewModelScope) survives rotation.
Never โ€” notifyDataSetChanged() in production, heavy work in onBindViewHolder, click listeners capturing stale positions.