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.
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.
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:
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.
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.
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.
| Callback | When it fires | Use 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) |
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:
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.
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.
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.
| LayoutManager | Use for | Key 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 |
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.
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.
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.
Performance โ keeping scroll at 60fps
RecyclerView is fast by default, but there are several high-impact optimisations every production list should use.
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.
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.
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.