Scroll Jump Postmortem: Paging3 with Room and RemoteMediator
Summary
A scroll jumping issue occurs in a LazyColumn when using Paging3 with Room as the single source of truth and RemoteMediator for network pagination. The problem manifests as the list jumping upward when new pages load, with the jump size increasing the deeper the user scrolls. This happens because of a fundamental mismatch between cursor-based API pagination and offset-based Room queries.
Root Cause
The root cause is the incompatibility between cursor-based and offset-based pagination strategies:
- API pagination: Uses cursor-based approach with
lastVisibleItemId(UUID) to fetch the next page - Room query: Uses offset-based
PagingSource<Int, Item>which calculates offsets by position
When RemoteMediator appends new items to the database, the Room PagingSource recalculates positions using offsets. Since offset-based pagination skips the first N items to reach page N, and new items are being appended at the end, the adapter attempts to reconcile existing item positions with newly loaded data. This causes the UI to jump because:
- The Paging adapter expects items at specific indices
- New data inserted at the end shifts all subsequent positions
- The deeper in the list, the greater the accumulated offset difference
Why This Happens in Real Systems
This issue commonly occurs in real systems when:
- Legacy API design: Many APIs use cursor-based pagination (by ID, timestamp, or UUID) for efficiency
- Database optimization: Room’s default
PagingSource<Int, T>uses OFFSET/LIMIT which is simple but problematic with dynamic data - Single Source of Truth requirement: Teams want Room to be the SSOT but don’t account for pagination strategy mismatches
- Historical data patterns: Append-only data (like chat messages, transaction history, or logs) exacerbates the problem because new pages always append to the end
Real-World Impact
- Poor user experience: Users lose their scroll position while browsing long lists
- Confusing navigation: The jump makes it appear as if content was removed or reordered
- Data inconsistency perception: Users may think data loading is broken
- Worse on large datasets: Impact grows with list size since offset calculations compound
Example or Code (if necessary and relevant)
The problematic Room query uses offset-based pagination:
@Query("SELECT * FROM ITEM ORDER BY TIME DESC")
fun getItemsPagingSource(): PagingSource
The RemoteMediator correctly uses cursor-based API calls:
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
lastItem?.let {
remoteDataSource.getHistories(lastVisibleItemId = it.id)
}
}
The mismatch occurs because Room doesn’t know the API uses cursors—it treats each page as an offset from the beginning of the result set.
How Senior Engineers Fix It
Senior engineers solve this by aligning the Room pagination strategy with the API’s cursor-based approach:
-
Use key-based PagingSource instead of offset-based: Replace
PagingSource<Int, Item>withPagingSource<YourCursorType, Item>where the key matches the API’s cursor (UUID, timestamp, etc.) -
Implement custom PagingSource: Create a PagingSource that uses the same cursor logic as the API
-
Use Room’s experimental offset-free paging: Leverage
PagingSourceimplementations that support key-based pagination
The fix requires the Room query to accept the cursor parameter:
@Query("SELECT * FROM ITEM WHERE time < :lastTime ORDER BY TIME DESC LIMIT :limit")
fun getItemsPagingSource(lastTime: Long, limit: Int): PagingSource
Then configure Pager with a custom keyProvider:
Pager(
config = PagingConfig(pageSize = 60),
pagingSourceFactory = { dao.getItemsPagingSource() },
remoteMediator = ItemRemoteMediator(...)
) {
it.remoteMediatorLoad ?: it.rowsBefore
}
Why Juniors Miss It
Juniors often miss this issue because:
- Surface-level understanding: They understand Paging3 separates UI from data loading but don’t realize pagination strategy must be consistent across all layers
- API-Room disconnect: The API uses cursors while Room uses offsets—these seem to work independently until combined with RemoteMediator
- Testing limitations: Testing with small datasets doesn’t reveal the problem since offset differences are minimal at the top of the list
- Documentation gaps: Paging3 documentation shows offset-based examples by default, making cursor-based approaches seem non-standard
- Focus on functionality over UX: Getting data to display correctly takes priority over scroll position stability