Paging 3 Explained

ARCHITECTURE › Components

Paging 3 solves one problem: show a list that might have thousands of rows without loading them all into memory or firing one giant call up front. Three pieces cooperate. A PagingSource knows how to fetch a single page given a key, a page number, a cursor, a timestamp. A Pager wraps a PagingSource factory plus a PagingConfig and exposes a Flow of PagingData. PagingData itself is the part people misjudge: it is not a finished list you can index into. It is a snapshot of paged content, delivered as a stream of incremental updates, pages appended, pages refreshed, that an adapter or collectAsLazyPagingItems consumes and renders, requesting more automatically as the user scrolls near the loaded edge.

The method you implement is PagingSource.load(). It receives a LoadParams with a key and a requested load size, and it must return a LoadResult. On success that is LoadResult.Page, carrying the data plus a prevKey and a nextKey, and on failure it is LoadResult.Error, which surfaces to the UI as a loadState error. The keys on a Page result matter as much as the data: a null prevKey means there is nothing before this page, and a null nextKey means there is nothing after it. Paging reads those nulls as a boundary and simply stops requesting more in that direction, it does not need to be told explicitly that pagination has ended.

There is a third LoadResult you will need less often but do need to know: LoadResult.Invalid. Return it when the PagingSource itself has gone stale, say the underlying dataset changed in a way that makes the existing keys unreliable. Invalid does not mean the same thing as Error. An error is a failed attempt you can retry with the same source. Invalid tells Paging the source can no longer be trusted at all, so Paging discards whatever it has loaded and asks the Pager's factory lambda to build a brand new PagingSource from scratch, rather than retrying the old one.

When Paging invalidates a source and builds a new one, getRefreshKey() decides where that new source should start loading from, so the user is not yanked back to the top of the list. Paging hands you a PagingState with an anchorPosition, the index of the item closest to wherever the user currently is. That index alone is not usable as a load key though, keys are page numbers or cursors, not list positions. So the real work is calling closestPageToPosition on that anchor to find the nearest already-loaded page, and deriving a key from that page's prevKey or nextKey. Returning null just tells Paging to restart from the initial key instead.

PagingConfig controls how aggressively pages load, and the setting people misunderstand most is prefetchDistance. It is not a timer and it is not something you call manually. It is how close to the loaded edge, in items, the user has to scroll before Paging automatically issues the next load. Cross that distance while scrolling toward the bottom and Paging fires an APPEND load on its own; cross it near the top and it fires PREPEND. There is no append() function you invoke from the UI layer, the whole mechanism is driven by which items the user has actually scrolled past.

The first load behaves differently from every load after it. initialLoadSize governs how much data that very first fetch pulls in, and by default it is not equal to pageSize, it is three times pageSize. The idea is simple: a single page's worth of rows often does not fill the screen, so the initial fetch grabs extra so the list starts reasonably full and does not need an immediate follow-up load the instant it appears. prefetchDistance, by contrast, defaults to pageSize itself. Both are configurable, but it is the 3x default on the initial load that catches people off guard when they are reasoning about how many network calls a screen makes on first appearance.

enablePlaceholders sounds like a simple flag, but it only actually does anything if the data source can tell Paging how many items exist in total. With that count, Paging can size the list correctly up front and emit null for the rows not yet loaded, which is what a placeholder row renders as. A Room-backed PagingSource gets this for free through a COUNT query. A hand-rolled PagingSource that never reports itemsBefore and itemsAfter on its LoadResult.Page will have the flag set to true and still show no placeholders, because Paging has no way to know the list's real size.

A Pager's flow is cold by default, so without intervention every new collector would trigger its own loading from scratch. That matters a lot in a ViewModel, because a configuration change like rotation recreates the UI and starts a brand new collection. Chain cachedIn(viewModelScope) onto the flow and that problem goes away: it caches the PagingData already loaded inside that scope and multicasts it to whoever collects, so a rotation reuses the pages already fetched instead of paying for them again from page one. It is worth being precise about what it is not: cachedIn does not write anything to disk and does not make the list work offline, that is a database and RemoteMediator concern entirely.

PagingData supports operators like map and insertSeparators, useful for turning raw items into UI models or injecting section headers between them. Where you place them in the chain matters. Because these transforms run again on every emission, putting them before cachedIn() means the cached, multicast result is already the transformed one. Put them after cachedIn() instead and every new collector, say after a rotation, has to redo the mapping and separator insertion from the raw data all over again, which defeats a good chunk of the point of caching in the first place.

Everything so far assumes a PagingSource that goes straight to the network or a database on its own. RemoteMediator exists for the layered case: network data feeding a local database that the UI actually pages over. The key idea to hold onto is that the database becomes the single source of truth. The PagingSource the UI collects from reads out of Room, not the network, while the mediator's load() function fetches network pages and writes them into that same database inside a transaction. load() receives a LoadType, REFRESH, PREPEND, or APPEND, so you know which direction to fetch, and it returns MediatorResult.Success with endOfPaginationReached telling Paging whether there is more to fetch in that direction, or MediatorResult.Error on failure.

RemoteMediator has one more override worth knowing: initialize(), called once before any load(), that returns an InitializeAction rather than data. Returning LAUNCH_INITIAL_REFRESH tells Paging to kick off a network refresh right away. Returning SKIP_INITIAL_REFRESH tells it to serve whatever is already cached in the database immediately and skip that redundant network hit, useful when you have timestamped the last successful sync and know the cached data is still fresh enough to show without waiting on the network.

On the UI side, loading and error state come from CombinedLoadStates, which groups a LoadState of Loading, NotLoading, or Error for each direction. The one that should drive a full-screen spinner, or a full-screen error view when the very first load fails, is loadState.refresh, because refresh covers both the initial load and any explicit user-triggered refresh. loadState.append and loadState.prepend only reflect loads happening at the bottom or top of a list the user can already see, so they belong on small inline indicators while scrolling, not on a full-screen state.

A failed load surfaces through loadState as an Error, and the natural response is a Retry button. The call to wire it to is retry() on the LazyPagingItems or the PagingDataAdapter, not refresh(). The distinction is easy to gloss over but matters in an interview: retry() re-attempts only the load or loads that actually failed and leaves the rest of the list exactly as it was, while refresh() throws everything away and reloads the whole list from the start, which is the wrong behavior for a button whose entire job is to fix one broken edge without disturbing what already loaded successfully.

In Compose, collectAsLazyPagingItems() turns the ViewModel's flow into a LazyPagingItems you render inside a LazyColumn, indexing it with items[index], which may be null when placeholders are enabled. The part worth getting right is the key parameter: pass items.itemKey mapped to a stable field like the item's id, not the raw list index. Indices are not stable under paging, they shift as new pages load in and as placeholders resolve into real rows, so a key built from the index would cause Compose to misidentify rows and animate or recompose the wrong ones as the list grows.

Back to Paging 3