AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

List & Grid Crash Debugging: RecyclerView & UITableView

NFNourin Mahfuj Finick··10 min read

Few failures frustrate mobile engineers more than a scrolling screen that works perfectly in development and then explodes on a real device. List and grid views sit at the heart of nearly every app — feeds, inboxes, settings, search results, product catalogs — and they are also among the most common sources of production crashes. On Android, the RecyclerView crash is usually an IndexOutOfBoundsException or the dreaded "Inconsistency detected. Invalid view holder adapter position" thrown by the InconsistencyDetector. On iOS, UITableView and UICollectionView raise NSInternalInconsistencyException with messages like "Invalid update: invalid number of rows in section 0." Both platforms share one underlying disease: the backing data source changed while the view was still laying out, so the counts you reported no longer match reality. This guide walks through the classic list and grid crashes on both platforms, the root causes behind them, and a repeatable debugging methodology — monitored end to end with Bugspulse.

Why Scrolling Screens Crash

RecyclerView, UITableView, and UICollectionView are all recycling views. They keep a small pool of row or cell objects and rebind them as the user scrolls, which keeps a ten-thousand-item feed smooth. That efficiency comes at a cost: the view maintains its own idea of how many items exist and expects the backing data to stay in lockstep. The moment the two drift apart — a row deleted off the main thread, an async response appended mid-layout — the view throws.

The exception differs by platform, but the shape is the same: you are debugging a consistency problem between your data source and the view that renders it.

Android: RecyclerView Crashes

IndexOutOfBoundsException in the Adapter

The most common RecyclerView crash looks innocent on the surface: IndexOutOfBoundsException: Index: 9, Size: 8. It almost always happens inside onBindViewHolder, when your adapter indexes into a list that shrank between the moment the layout manager asked for item 9 and the moment the binder ran.

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.bind(items[position]) // IndexOutOfBoundsException lands here
}

The classic trigger is mutating the list on a background thread while the layout manager is walking through positions. If a coroutine removes an element without hopping back to the main thread, items.size can change mid-pass. The fix is twofold: never mutate the adapter's backing list off the main thread, and guard reads so that a stale position degrades gracefully instead of throwing.

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = items.getOrNull(position) ?: return
    holder.bind(item)
}

That guard stops the crash but not the race; the real fix is applying every mutation on the main thread with a matching notify call.

The InconsistencyDetector Error

The scariest Android list crash is not the exception itself but the stack trace that accompanies it:

java.lang.IllegalStateException: Inconsistency detected. Invalid view holder adapter positionViewHolder
    at androidx.recyclerview.widget.RecyclerView$Recycler.validateViewHolderForOffsetPosition

The RecyclerView InconsistencyDetector fires when a recycled ViewHolder reports a position that no longer corresponds to the adapter's current data set. In practice this happens when you call notifyDataSetChanged() (or a targeted notify) while RecyclerView is already in the middle of a layout pass, or when you combine manual adapter manipulation with a library that is also manipulating the adapter.

The InconsistencyDetector runs only in debug builds, which is why the crash often "disappears" in production while the stale-position bug silently renders the wrong rows — treat every debug inconsistency as a production bug in waiting. A common culprit is calling notifyDataSetChanged() inside onBindViewHolder or a scroll callback, re-entering layout. Move those mutations outside the layout pass.

notifyDataSetChanged() During Layout

notifyDataSetChanged() is a blunt instrument that throws away the entire view pool and rebinds everything. Called during a layout pass, inside onBindViewHolder, or from an observer firing mid-layout, it re-enters the layout manager and corrupts its position bookkeeping — the direct parent of both crashes above.

Replace blanket notifies with targeted, diffed updates. The RecyclerView.Adapter API exposes notifyItemInserted, notifyItemRemoved, notifyItemChanged, and notifyItemRangeChanged, each of which lets the layout manager animate and reconcile positions precisely. Better still, let DiffUtil or the ListAdapter wrapper compute the minimal diff for you:

class FeedAdapter : ListAdapter<Post, FeedAdapter.ViewHolder>(PostDiff) {
    object PostDiff : DiffUtil.ItemCallback<Post>() {
        override fun areItemsTheSame(a: Post, b: Post) = a.id == b.id
        override fun areContentsTheSame(a: Post, b: Post) = a == b
    }
    // submitList(list) handles diffing + notifies internally
}

Diffing removes the need to hand-roll notify calls entirely, which is where most inconsistency crashes originate.

getAdapterPosition() and Recycled View Holders

ViewHolder callbacks must use getBindingAdapterPosition() (or getAbsoluteAdapterPosition()) rather than the deprecated getAdapterPosition(). During an animation or diff, a ViewHolder can temporarily return NO_POSITION (-1), and code that blindly indexes with -1 throws. The modern accessors expose that state so you can short-circuit:

holder.itemView.setOnClickListener {
    val pos = holder.bindingAdapterPosition
    if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
    onItemClick(items[pos])
}

Stable IDs and DiffUtil Mismatches

setHasStableIds(true) promises RecyclerView that every getItemId() is unique and immutable. When two items share an ID — common when keying on a non-unique field like a name — RecyclerView reuses the wrong ViewHolder, binding mismatched data or hitting the inconsistency detector. Use a genuinely unique key (the database primary key or a UUID), never a hashCode.

iOS: UITableView and UICollectionView Crashes

The "Invalid Update" NSInternalInconsistencyException

On iOS, the equivalent explosion is an NSInternalInconsistencyException with a message that reads like a riddle:

Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (12) must be equal to the number of rows contained in that section before the update (10), plus or minus the number of rows inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).

UIKit performs its own arithmetic after every batch of insertRows/deleteRows/reloadRows calls and crashes when numberOfRows(inSection:) does not reconcile with those operations. Common causes: mutating the model between beginUpdates and endUpdates, applying an update against a stale count, or an off-by-one in section or row indices.

The reliable fix is to perform exactly one model mutation per batch and to wrap it so the data source and the view stay synchronized:

tableView.performBatchUpdates({
    items.remove(at: indexPath.row)          // 1. mutate the model
    tableView.deleteRows(at: [indexPath],    // 2. tell the table
                         with: .automatic)
}, completion: nil)

If the arithmetic still does not reconcile, the bug is elsewhere in your numberOfRows(inSection:) — the count it returns does not match the real model. This is covered in depth alongside other UIKit state bugs in our guide to debugging iOS Auto Layout and layout crashes.

"No Cell Registered for Identifier"

Another frequent UICollectionView crash is:

NSInternalInconsistencyException: could not dequeue a view of kind: UICollectionElementKindCell with identifier MyCell - must register a nib or a class for the identifier or connect a prototype cell in a storyboard

The UICollectionView requires every dequeued reuse identifier to be registered first, in code with register(_:forCellWithReuseIdentifier:) or via a storyboard prototype. A typo (MyCell registered, MyCel dequeued) or registering after the first dequeue is enough to crash — the same rule applies to supplementary and header/footer views. Keep identifiers in a single typed constant.

Diffable Data Source Snapshot Bugs

The modern, correct way to drive a UITableView or collection view is a diffable data source, which removes the error-prone insert/delete arithmetic altogether. With UICollectionViewDiffableDataSource (or the table variant UITableViewDiffableDataSource), you build a snapshot — an immutable description of the entire UI state — and apply it. UIKit computes the diff, so there is no insert/delete bookkeeping to get wrong.

The remaining crashes come from snapshot misuse: applying from a background queue (snapshots are not thread-safe), applying to a data source whose cell provider was never set, or mixing manual reloadData() with snapshot applies. The discipline: the snapshot is the single source of truth, applied only on the main thread.

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)

cellForRowAt Returning nil and Reuse State Leakage

cellForRowAt should never return nil — force-unwrapping a cell that failed to dequeue is the usual way developers do it. More subtle is reuse state leakage: recycled cells keep any property you forgot to reset, so a checkbox checked in row 2 shows up checked in row 40 after scrolling, and a prepareForReuse that skips clearing an image or badge can render stale data or trigger a later crash. Reset every mutable property in prepareForReuse, and let bind(_:) establish the full visual state.

The Shared Root Causes

Strip away the platform syntax and every crash above reduces to one of five causes:

  • Mutating the data source while a render or update is in flight. A background write, a re-entrant observer, or a callback that fires during layout.
  • Async data arrival. A network response that appends, removes, or reorders items after the view has already started rendering the previous batch.
  • Off-by-one errors in section and row counts, usually at the boundary between an empty and a non-empty state.
  • Reuse-state leakage in recycled rows and cells.
  • A missing or mistimed reload. Forgetting reloadData/notifyDataSetChanged after a change, or calling it at the wrong moment.

The common thread is that list and grid crashes are rarely about the view itself; they are about the contract between data and view, and the fix is almost always to make one of them the single source of truth — the same discipline we cover in our thread-safety guide.

A Debugging Methodology That Actually Works

When a list crash lands in your crash tracker, work through these steps in order instead of guessing.

First, read the whole exception message. On iOS, the "Invalid update" message literally tells you the arithmetic: before, after, inserted, deleted, moved. On Android, the InconsistencyDetector message names the ViewHolder and the adapter position that drifted. The answer is usually in the message.

Second, assert data-source consistency before every update. Add a debug assertion that the model's section and item counts match what the view expects, and run it before each update batch. A failing assertion in development is a crash you never ship.

Third, move to diffable snapshots and ListAdapter/DiffUtil. Both platforms now ship a first-party diffing mechanism that eliminates manual insert/delete bookkeeping. Adopting it removes the entire class of off-by-one and invalid-update crashes.

Fourth, unit-test your adapters and data sources. Feed your adapter a sequence of mutations — insert at the head, delete the last row, clear to empty, refill — and assert the resulting counts and positions.

Fifth, monitor with Bugspulse. Wire breadcrumbs around every data mutation and diff/snapshot apply so you can see the exact sequence of state changes that preceded a crash. Correlate it with the async callback that touched the list, and the root cause reveals itself in minutes rather than days. If the crash is happening inside a navigation flow that also manipulates the list, our navigation stack debugging guide is the natural next read.

List and grid crashes surface long after the bug that caused them, which makes them feel chaotic. Treat your data source and view as two halves of a single contract, hand reconciliation to DiffUtil or a diffable snapshot, and they become one of the most predictable failure classes to eliminate.

Ready to catch list crashes before your users do? Start your free Bugspulse account and get real-time RecyclerView and UITableView crash reporting with breadcrumbs, symbolication, and alerting in minutes.