Paging 3: PagingSource, RemoteMediator & Flow Integration
Master infinite scrolling with offline-first architecture using Paging 3
Open interactive version (quiz + challenge)Real-world analogy
Paging 3 is like a librarian who fetches books shelf by shelf instead of dumping the entire library on your desk. The PagingSource is the librarian's catalog, RemoteMediator is the inter-library loan system that also stocks your local shelves, and Flow is the conveyor belt delivering books to your reading table exactly when you need them.
What is it?
PagingSource loads one page of data at a time in Jetpack Paging 3 — pairs with RemoteMediator for offline sync and Flow of PagingData, with Kotlin code.
Real-world relevance
Every production app with a feed, search results, or message list uses pagination. Instagram uses cursor-based paging for its feed, Slack pages message history with RemoteMediator-style caching, and Twitter's timeline relies on keyset pagination. Paging 3 is the standard approach in Android for all these patterns, handling the complex state management of multi-source, bidirectional loading.
Key points
- PagingSource & LoadResult — PagingSource defines how to load pages of data. You override the `load()` suspend function and return either `LoadResult.Page(data, prevKey, nextKey)` on success or `LoadResult.Error(throwable)` on failure. The Key is typically an Int (page number) or String (cursor). PagingSource is single-shot — a new instance is created for each refresh via PagingSourceFactory.
- PagingConfig Tuning — PagingConfig controls loading behavior: `pageSize` sets items per page (typically 20-30), `prefetchDistance` triggers loading when the user is N items from the end (default: pageSize), `initialLoadSize` sets the first load size (default: 3 * pageSize), `maxSize` caps items in memory (must be >= pageSize + 2 * prefetchDistance), and `enablePlaceholders` shows null placeholders for unloaded items.
- RemoteMediator for Offline-First — RemoteMediator bridges network and local database. Its `load(loadType, state)` method handles REFRESH, PREPEND, and APPEND actions. It fetches from the API, saves to Room, and returns `MediatorResult.Success(endOfPaginationReached)` or `MediatorResult.Error(e)`. The PagingSource then reads exclusively from the database, achieving true offline-first paging.
- Pager & Flow> — The Pager class takes PagingConfig and a PagingSourceFactory (plus optional RemoteMediator) and exposes `flow: Flow>`. This Flow emits new PagingData snapshots whenever invalidation occurs. You collect this in ViewModel and pass it to UI. PagingData is opaque — you cannot inspect its contents directly, only transform it via `.map()`, `.filter()`, `.insertSeparators()`.
- PagingDataAdapter & DiffUtil — In RecyclerView, use PagingDataAdapter which extends RecyclerView.Adapter with built-in DiffUtil support. You provide a DiffUtil.ItemCallback and call `adapter.submitData(lifecycle, pagingData)` from the Activity/Fragment. The adapter handles loading states, placeholders, and efficient diffing automatically.
- LazyPagingItems in Compose — In Jetpack Compose, collect PagingData with `flow.collectAsLazyPagingItems()`. This returns LazyPagingItems which integrates with LazyColumn via `items(lazyPagingItems)`. Access items with `lazyPagingItems[index]` which triggers loading. Check `loadState.refresh`, `loadState.append`, and `loadState.prepend` for loading/error states.
- LoadState & CombinedLoadStates — CombinedLoadStates provides `refresh`, `prepend`, `append` each as LoadState (Loading, NotLoading, or Error). The `source` and `mediator` properties distinguish local vs remote states. Use `loadState.refresh is LoadState.Loading` for initial loading indicators and `loadState.append is LoadState.Error` for retry footers.
- PagingData Transformations — Transform PagingData using `.map {}` for item transformation, `.filter {}` to exclude items, `.insertSeparators { before, after -> }` for headers/date separators, and `.insertHeaderItem()` / `.insertFooterItem()` for static items. All transformations are applied lazily and maintain paging behavior.
- Error Handling & Retry — Implement retry by exposing `adapter.retry()` in XML or checking `lazyPagingItems.loadState` in Compose. For RemoteMediator errors, the system automatically retries on the next user scroll. Use `flow.cachedIn(viewModelScope)` to survive configuration changes and prevent redundant loads.
- Invalidation & Refresh — Call `pagingSource.invalidate()` to trigger a fresh load — typically from Room's InvalidationTracker when data changes. For manual refresh, use `adapter.refresh()` or re-collect the Pager flow. RemoteMediator's REFRESH loadType handles pull-to-refresh scenarios by clearing and re-fetching.
- Testing PagingSource — Test PagingSource by calling `load()` directly with `LoadParams.Refresh(key, loadSize, placeholders)` and asserting the LoadResult. Use `TestPager(PagingConfig, pagingSourceFactory)` for integration tests that simulate real paging behavior. For snapshot testing, use `AsyncPagingDataDiffer` to collect PagingData into a list.
- Performance & Memory — Paging 3 manages memory via `maxSize` eviction. Use `cachedIn(scope)` to share PagingData across collectors. Avoid transforming PagingData outside the paging pipeline (e.g., `.toList()`) as it defeats lazy loading. Profile with Android Studio's Memory Profiler to verify items are evicted correctly.
- Quick answer: what is PagingSource? — PagingSource is the abstract class in Jetpack Paging 3 that defines exactly one thing — how to load one page of data, via its load() method — from a network call, a Room DAO, or a combination of both through RemoteMediator. A Pager wraps a PagingSource factory and exposes the results as Flow> for the UI to collect with collectAsLazyPagingItems() (Compose) or a PagingDataAdapter (RecyclerView).
- Minimal PagingSource — smallest working example — Skip RemoteMediator and Room for a moment — here is the smallest PagingSource that compiles: one network call per page, plus prevKey/nextKey for the next load. Every network-only paging screen (search results, a simple feed) starts from exactly this shape.
Code example
// === PagingSource Implementation ===
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String
) : PagingSource<Int, Article>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val response = api.searchArticles(
query = query,
page = page,
pageSize = params.loadSize
)
LoadResult.Page(
data = response.articles,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.articles.isEmpty()) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
// === RemoteMediator Implementation ===
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> {
val remoteKey = db.remoteKeyDao().getKey("articles")
remoteKey?.nextPage
?: return MediatorResult.Success(
endOfPaginationReached = true
)
}
}
return try {
val response = api.getArticles(
page = page,
pageSize = state.config.pageSize
)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.articleDao().clearAll()
db.remoteKeyDao().deleteKey("articles")
}
db.remoteKeyDao().insertKey(
RemoteKey("articles", nextPage = page + 1)
)
db.articleDao().insertAll(
response.articles.map { it.toEntity() }
)
}
MediatorResult.Success(
endOfPaginationReached = response.articles.isEmpty()
)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
// === ViewModel ===
class ArticleViewModel(
private val api: ArticleApi,
private val db: AppDatabase
) : ViewModel() {
@OptIn(ExperimentalPagingApi::class)
val articles: Flow<PagingData<Article>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
initialLoadSize = 40,
maxSize = 200
),
remoteMediator = ArticleRemoteMediator(api, db),
pagingSourceFactory = { db.articleDao().pagingSource() }
).flow
.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
}
.cachedIn(viewModelScope)
}
// === Compose UI ===
@Composable
fun ArticleList(viewModel: ArticleViewModel) {
val lazyPagingItems = viewModel.articles
.collectAsLazyPagingItems()
LazyColumn {
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.id }
) { index ->
val article = lazyPagingItems[index]
if (article != null) {
ArticleCard(article = article)
} else {
ArticlePlaceholder()
}
}
// Append loading indicator
when (lazyPagingItems.loadState.append) {
is LoadState.Loading -> {
item { LoadingFooter() }
}
is LoadState.Error -> {
item {
ErrorFooter(
onRetry = { lazyPagingItems.retry() }
)
}
}
else -> {}
}
}
// Refresh state
if (lazyPagingItems.loadState.refresh is LoadState.Loading) {
FullScreenLoading()
}
}Line-by-line walkthrough
- 1. ArticlePagingSource extends PagingSource — Int is the page key type, Article is the data type.
- 2. load() receives LoadParams containing the key (page number) and loadSize. We default to page 1 if key is null (initial load).
- 3. On success, LoadResult.Page wraps the data with navigation keys. prevKey is null for page 1 (no previous), nextKey is null when data is empty (end reached).
- 4. getRefreshKey() determines which page to load on invalidation, using anchorPosition to find the closest page.
- 5. ArticleRemoteMediator handles LoadType.REFRESH (pull-to-refresh), PREPEND (load earlier), and APPEND (load more).
- 6. PREPEND returns Success with endOfPaginationReached=true since our feed only goes forward.
- 7. APPEND reads the next page number from a RemoteKey stored in Room. No key means we've reached the end.
- 8. db.withTransaction ensures clearing old data and inserting new data + remote keys happens atomically.
- 9. The ViewModel creates a Pager with PagingConfig, RemoteMediator, and a PagingSourceFactory from Room's DAO.
- 10. The flow is mapped to convert entities to domain models and cachedIn(viewModelScope) for configuration change survival.
- 11. In Compose, collectAsLazyPagingItems() bridges Flow to LazyColumn-compatible items.
- 12. loadState.append and loadState.refresh are checked to show loading indicators and error/retry UI.
Spot the bug
class UserPagingSource(
private val api: UserApi
) : PagingSource<Int, User>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, User> {
val page = params.key ?: 0
val response = api.getUsers(page, params.loadSize)
return LoadResult.Page(
data = response.users,
prevKey = page - 1,
nextKey = page + 1
)
}
override fun getRefreshKey(
state: PagingState<Int, User>
): Int? = null
}Need a hint?
What happens when prevKey is 0 - 1 = -1 on the first page? And what if the API returns an empty list?
Show answer
Two bugs: (1) prevKey should be `if (page == 0) null else page - 1` to prevent loading page -1. (2) nextKey should be `if (response.users.isEmpty()) null else page + 1` to signal end of pagination. Also missing try-catch for network errors — load() should catch exceptions and return LoadResult.Error(e).
Explain like I'm 5
Imagine you have a really long picture book with 10,000 pages. You can't carry the whole book at once — it's too heavy! So instead, a helper brings you 20 pages at a time. When you're almost done reading those, they run and grab the next 20. If the internet goes out (like the helper getting lost), you can still read the pages they already brought because they made copies and put them in your desk drawer (that's the database). That's Paging 3 — a smart helper that brings you just enough data, saves copies locally, and keeps the pages coming smoothly!
Fun fact
Before Paging 3, Android had Paging 1 and 2 which used LiveData and required a DataSource.Factory pattern. Paging 3 was a complete rewrite with Kotlin coroutines and Flow at its core, and the RemoteMediator concept was inspired by the Boundary Callback pattern but made dramatically simpler. Google's own apps like Google News and Play Store use similar paging architectures handling millions of items.
Hands-on challenge
Build a complete offline-first paging setup for a GitHub repository search. Implement: (1) A GithubPagingSource that uses cursor-based pagination with the GitHub API, (2) A RemoteMediator that caches results in Room with remote keys, (3) A ViewModel that exposes Flow> with .insertSeparators() to add star-count headers (e.g., '1000+ stars', '500+ stars'), (4) A Compose UI with LazyColumn showing loading, error, and retry states. Handle the GitHub API rate limit (403) gracefully by showing a countdown timer.
More resources
- Paging 3 Official Guide (Android Developers)
- Android Paging Codelab (Google Codelabs)
- Paging 3 - Getting to the first page (Android Developers YouTube)
- Paging 3 with RemoteMediator (ProAndroidDev)