Paging 3: PagingSource, RemoteMediator & Flow Integration
Master infinite scrolling with offline-first architecture using Paging 3
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Paging 3 is a Jetpack library for loading and displaying large datasets incrementally. It provides PagingSource for single-source loading, RemoteMediator for network-plus-database offline-first patterns, and Flow for reactive data delivery to both RecyclerView (via PagingDataAdapter) and Compose (via LazyPagingItems), with built-in error handling, retry, and transformation support.
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.
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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
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)