Twenty-eight days after releasing v2.0.0, a red graph appeared in my Firebase Crashlytics dashboard.
IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder — over 50 users were hitting the same crash. The fact that I had been proud of the v2.0.0 rewrite made it sting even more.
I tried notifyDataSetChanged(). I switched to DiffUtil. None of the surface-level fixes moved the Crashlytics numbers. It wasn't until I talked through the problem with Claude Code that I landed on the fix: a defensive copy pattern. This is a record of how I got there.
Why RecyclerView's IndexOutOfBoundsException Is So Stubborn
The stack trace alone doesn't tell you what's wrong:
java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder
at androidx.recyclerview.widget.RecyclerView$Recycler.validateViewHolderForOffscreenChange
RecyclerView operates under an assumption: that the backing data source isn't being changed by something else while it's doing layout work. When the adapter's reference to a list and a background update on that same list happen to collide in timing, RecyclerView's internal "expected size" diverges from the real size — and it crashes.
The real trouble is that this divergence doesn't happen consistently. It depends on thread scheduling across CPU cores, making it nearly impossible to reproduce locally, even when Crashlytics shows it happening regularly in production.
The Claude Code Conversation That Found the Root Cause
I pasted the core code — WallpaperListAdapter and WallpaperRepository — and asked for a diagnosis:
Me: RecyclerView IndexOutOfBoundsExceptions are happening frequently in production.
I've attached the stack trace and the relevant classes.
Can you identify which code path is causing this?
Claude Code: I can see that WallpaperRepository.getWallpapers() returns the same
List<Wallpaper> reference that the repository holds internally.
When background pagination adds items to _wallpaperList, the adapter
still holds a reference to that same list object. This is the source
of the inconsistency — the adapter's size expectations are invalidated
by external mutations.
"The same list object" — that was the key phrase. My code was passing a direct reference to the repository's internal _wallpaperList to the adapter. While a background thread added new pages to _wallpaperList, RecyclerView's draw thread was still working with its "pre-mutation" size assumption. That's the crash.
I had been developing indie apps since 2013 — my wallpaper apps have reached over 50 million downloads combined — and thread-safety bugs like this are exactly the kind of thing that feels fine until production shows you otherwise.
The Fix: Defensive Copies
Claude Code's suggested fix was concise.
Before (unsafe):
// Repository was leaking a mutable reference
fun getWallpapers(): List<Wallpaper> {
return _wallpaperList // External callers get the live reference
}After (safe):
// Return an immutable snapshot instead
fun getWallpapers(): List<Wallpaper> {
return _wallpaperList.toList() // Copy at the moment of the call
}Kotlin's toList() creates an immutable snapshot of the list at that instant. The adapter gets a frozen view of the data; whatever the repository does to _wallpaperList afterward can't affect what RecyclerView is iterating over.
I also updated the adapter itself to always store a defensive copy on updates:
class WallpaperListAdapter : RecyclerView.Adapter<WallpaperViewHolder>() {
private var wallpapers: List<Wallpaper> = emptyList()
fun submitList(newList: List<Wallpaper>) {
// Defensive copy + DiffUtil for smooth animations
val defensiveCopy = newList.toList()
val diffResult = DiffUtil.calculateDiff(
WallpaperDiffCallback(wallpapers, defensiveCopy)
)
wallpapers = defensiveCopy
diffResult.dispatchUpdatesTo(this)
}
override fun getItemCount(): Int = wallpapers.size
// Rest of the adapter implementation
}Combining the defensive copy with DiffUtil preserved the animated item transitions while making updates thread-safe.
What the Crashlytics Data Looked Like After v2.1.0
I rolled out the fix with staged rollout (5% → 25% → 50% → 100%).
The results:
IndexOutOfBoundsExceptioncrashes: zero- Crash-free users: 99.8%, stable throughout rollout
- No crash spikes during staged rollout — confident to push to 100%
A 28-day problem resolved with a few lines of change.
What Changed About How I Debug
The more interesting takeaway is how framing the question differently changed the outcome.
When I was debugging on my own, I was asking "where do I put the fix?" — scanning for the broken line. When I asked Claude Code for a diagnosis instead, the question became "what is the structural problem?" That reframing surfaced the architecture issue: a mutable reference leaking from a repository into a UI component.
For solo developers who don't have teammates to review code, this kind of structured diagnosis conversation is genuinely useful. You get the equivalent of a code review focused on root causes, not just symptoms.
Other Places This Pattern Applies
Defensive copies matter anywhere a mutable collection is shared across boundaries:
// ❌ Common unsafe pattern
class CategoryRepository {
private val _categories = mutableListOf<Category>()
// Leaking a live reference
fun getCategories(): List<Category> = _categories
}
// ✅ Safe pattern
class CategoryRepository {
private val _categories = mutableListOf<Category>()
// Return a snapshot
fun getCategories(): List<Category> = _categories.toList()
// Or use StateFlow — but still copy on emission
private val _categoriesFlow = MutableStateFlow<List<Category>>(emptyList())
val categoriesFlow: StateFlow<List<Category>> = _categoriesFlow.asStateFlow()
fun update(newCategories: List<Category>) {
_categoriesFlow.value = newCategories.toList() // Always copy before assigning
}
}Even with StateFlow or LiveData, if you assign a mutable list directly to _flow.value, you're back to the same problem. The habit I've settled on: always call .toList() before passing a list across a thread or component boundary.
ViewPager2 and custom Views that hold data lists can hit the same issue — it's not RecyclerView-specific, just where it tends to surface first.
Beautiful HD Wallpapers continues to receive updates. The next crash I'll write about is the Java 8 NoClassDefFoundError from Glide 5.0.5 + AGP 9.x — which turned out to be a single-line fix (coreLibraryDesugaringEnabled true) with a surprisingly convoluted path to finding it.
If you're staring at the same IndexOutOfBoundsException, the first place to check is how your repository hands data to your adapter. The fix is usually smaller than the frustration suggests.