Paging 3 with Room: Loading Big Local Lists Without the Offset Bookkeeping
Room can return a PagingSource directly from a DAO, and Paging 3 handles the incremental loading, diffing, and invalidation. Here's the minimal wiring, the cachedIn gotcha, and how to verify it.
17 Sept 2025, 13:35 UTC

You have 50,000 rows in a Room table and a RecyclerView that needs to show all of them. Loading everything at once is out of the question, and hand-rolling offset/limit queries with notifyDataSetChanged is a reliable way to ship scroll-position bugs. The useful takeaway: Room can hand you a PagingSource directly, and Paging 3 handles the incremental loading, diffing, and invalidation for you — if you wire up three pieces correctly.
The three pieces you actually need
Paging 3's setup looks intimidating in diagrams, but for a purely local Room-backed list it comes down to three things.
First, your DAO returns a PagingSource instead of a List or Flow:
@Dao
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestamp DESC")
fun pagingSource(): PagingSource<Int, Message>
}Room generates the paging implementation for you — no offset math, no manual page tracking. When the underlying table changes (an insert or delete), Room invalidates the source and Paging 3 quietly reloads.
Second, a Pager in your ViewModel wraps that source in a Flow<PagingData>:
class MessageViewModel(dao: MessageDao) : ViewModel() {
val messages: Flow<PagingData<Message>> = Pager(
config = PagingConfig(
pageSize = 30,
prefetchDistance = 15,
enablePlaceholders = false
),
pagingSourceFactory = { dao.pagingSource() }
).flow.cachedIn(viewModelScope)
}Third, the UI collects that flow into a PagingDataAdapter, which is a ListAdapter-style adapter that diffs pages internally using the DiffUtil.ItemCallback you supply:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.messages.collectLatest { adapter.submitData(it) }
}
}That's the whole pipeline. No notifyDataSetChanged, no scroll listeners doing math.
cachedIn is not optional
The single most common Paging 3 bug report boils down to a missing cachedIn(viewModelScope). Without it, every configuration change — rotation being the classic case — recreates the flow and restarts loading from page one, dumping the user back at the top of the list. With it, the loaded pages survive in the ViewModel's scope and the list picks up where it left off.
Note the scope matters: cachedIn must outlive the UI. Caching in a lifecycleScope tied to the activity defeats the purpose.
Tuning PagingConfig without guessing
PagingConfig has three knobs that matter for local data:
- pageSize — rows fetched per query. Too small (say 5) and you pay query overhead constantly while flinging; too large (say 200) and a single page load can jank on low-end devices. Values in the 20–60 range are a reasonable starting point for typical row sizes.
- prefetchDistance — how far from the loaded edge Paging starts fetching the next page. Roughly half to a full pageSize is typical.
- enablePlaceholders — for Room, the total count is known, so placeholders can render the full scrollbar immediately. The trade-off: users can scroll into not-yet-loaded blank items. For local data loads are fast enough that this is often fine, but test it; many teams leave placeholders off.
There's no universally correct configuration — tune against your actual row weight and test on the slowest device you support.
When you don't need the rest of Paging 3
Paging 3's flagship feature is RemoteMediator, which coordinates network pages written into Room as the single source of truth. It's the right tool for a feed backed by an API, but it adds real machinery: remote keys, transaction boundaries, refresh-vs-append logic. For a purely local dataset — the case in this post — skip it entirely. A DAO returning PagingSource plus a Pager is sufficient, and adding a mediator buys you nothing but failure modes.
Similarly, if you're migrating an older codebase, don't mix Paging 2's LiveData<PagedList> with Paging 3 flows in adjacent screens. Migrate a screen fully or document the boundary explicitly; half-migrated paging code is harder to debug than either version alone.
Verifying it actually works
Don't trust the wiring until you've watched it behave:
- Seed the table with a few thousand generated rows. Scroll steadily and confirm in logcat (Room query logging) or the Layout Inspector that queries fire incrementally rather than one giant SELECT.
- Rotate the device. The list position and loaded items should survive — this validates
cachedIn. - With the list open, insert or delete a row from another screen or a test hook. The UI should update on its own, demonstrating
PagingSourceinvalidation. - Hook
adapter.addLoadStateListenerand log the states while scrolling to the end; you should seeLoadState.NotLoading(endOfPaginationReached = true)rather than endless loading.
One caveat before you copy the snippets: the Room-to-PagingSource integration and Paging 3's API surface are version-sensitive. Check your dependency catalog against the current stable releases of androidx.paging and androidx.room (and the room-paging artifact) before committing to exact imports.
Start with the smallest version of this — one entity, one DAO, one adapter — and confirm incremental loading with your own eyes. Then tune PagingConfig against real data on a real low-end device. That fifteen minutes of verification is worth more than any amount of config fiddling in the abstract.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.