Paging 3 Deep Dive
Paging 3 is the official solution for paginated lists. It handles loading state, retries, cache, prefetching, and Compose integration — but its three-layer architecture (PagingSource → Pager → PagingData) takes a moment to internalize. This chapter walks the full surface.
The architecture
┌──────────────────────────────────────────────────────────────┐
│ UI (LazyColumn) │
│ collectAsLazyPagingItems() → LazyPagingItems<T> │
├──────────────────────────────────────────────────────────────┤
│ ViewModel │
│ Pager.flow → Flow<PagingData<T>>.cachedIn(viewModelScope) │
├──────────────────────────────────────────────────────────────┤
│ Pager (configuration) │
│ pageSize, prefetchDistance, RemoteMediator │
├──────────────────────────────────────────────────────────────┤
│ PagingSource<Key, Value> │
│ load(LoadParams<Key>) → LoadResult<Key, Value> │
└──────────────────────────────────────────────────────────────┘
Three layers, three responsibilities:
- PagingSource — "load one page from this key"
- Pager — "load with this config; expose as Flow"
- UI — "render with this Flow"
Setup
// libs.versions.toml
paging = "3.3.4"
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" }
room-paging = { module = "androidx.room:room-paging", version = "2.6.1" }
Network-only paging
Simplest case — paginated REST endpoint, no local cache:
class UserPagingSource @Inject constructor(
private val api: UserApi,
private val query: String
) : PagingSource<Int, User>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
val page = params.key ?: 1
return try {
val response = api.searchUsers(query, page = page, pageSize = params.loadSize)
LoadResult.Page(
data = response.users,
prevKey = if (page > 1) page - 1 else null,
nextKey = if (response.hasMore) page + 1 else null
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? =
state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
@HiltViewModel
class UserSearchViewModel @Inject constructor(
private val api: UserApi
) : ViewModel() {
private val _query = MutableStateFlow("")
val users: Flow<PagingData<User>> = _query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false,
initialLoadSize = 40
),
pagingSourceFactory = { UserPagingSource(api, query) }
).flow
}
.cachedIn(viewModelScope)
fun onQueryChanged(query: String) { _query.value = query }
}
.cachedIn(viewModelScope) is mandatory — it survives Compose
recomposition and config changes. Without it, scrolling resets on every
recomposition.
Compose integration
@Composable
fun UserSearchScreen(viewModel: UserSearchViewModel = hiltViewModel()) {
val users = viewModel.users.collectAsLazyPagingItems()
LazyColumn {
items(
count = users.itemCount,
key = users.itemKey { it.id },
contentType = users.itemContentType { "user" }
) { index ->
val user = users[index]
if (user != null) {
UserRow(user)
} else {
UserPlaceholder()
}
}
// Loading states for append/refresh
when (val append = users.loadState.append) {
is LoadState.Loading -> item { LoadingIndicator() }
is LoadState.Error -> item { ErrorItem(append.error) { users.retry() } }
else -> Unit
}
when (val refresh = users.loadState.refresh) {
is LoadState.Loading -> {
if (users.itemCount == 0) item { FullScreenLoader() }
}
is LoadState.Error -> {
if (users.itemCount == 0) item { FullScreenError(refresh.error) { users.retry() } }
}
else -> Unit
}
}
}
Load states
| LoadState | When |
|---|---|
Refresh | Initial load or pull-to-refresh |
Prepend | Loading items before the current view |
Append | Loading items after the current view |
Loading | Network in flight |
NotLoading | Idle |
Error | Last attempt failed; error: Throwable |
Render each meaningfully — full-screen state if no items, inline footer if items exist.
Local-first paging (Paging + Room)
For offline-first apps, Room is the source of truth and the network
refreshes via RemoteMediator:
@Dao
interface ProductDao {
@Query("SELECT * FROM products ORDER BY rank ASC")
fun pagingSource(): PagingSource<Int, ProductEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<ProductEntity>)
@Query("DELETE FROM products")
suspend fun clear()
}
Room generates a PagingSource<Int, ProductEntity> automatically. UI
observes it; Room re-emits when the table changes.
RemoteMediator — refresh from network
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator @Inject constructor(
private val api: ProductApi,
private val db: AppDatabase
) : RemoteMediator<Int, ProductEntity>() {
override suspend fun initialize(): InitializeAction {
val lastFetch = db.remoteKeyDao().getLastFetchTime() ?: 0
val cacheTimeout = TimeUnit.HOURS.toMillis(1)
return if (System.currentTimeMillis() - lastFetch < cacheTimeout) {
InitializeAction.SKIP_INITIAL_REFRESH
} else {
InitializeAction.LAUNCH_INITIAL_REFRESH
}
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ProductEntity>
): MediatorResult {
val nextKey: Int = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastKey = db.remoteKeyDao().getLast() ?: return MediatorResult.Success(true)
lastKey.nextPage ?: return MediatorResult.Success(true)
}
}
return try {
val response = api.products(page = nextKey, pageSize = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.remoteKeyDao().clear()
db.productDao().clear()
}
db.productDao().insertAll(response.items.map(ProductDto::toEntity))
db.remoteKeyDao().insert(RemoteKey(
nextPage = if (response.hasMore) nextKey + 1 else null,
fetchedAt = System.currentTimeMillis()
))
}
MediatorResult.Success(endOfPaginationReached = !response.hasMore)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
@OptIn(ExperimentalPagingApi::class)
@Singleton
class ProductRepository @Inject constructor(
private val db: AppDatabase,
private val mediator: ProductRemoteMediator
) {
fun pager(): Flow<PagingData<Product>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5),
remoteMediator = mediator,
pagingSourceFactory = { db.productDao().pagingSource() }
).flow.map { paging -> paging.map { it.toDomain() } }
}
Now the UI sees a continuous Flow — cached items first, then network refresh updates Room, which re-emits to the UI. Offline-tolerant.
RemoteKey table
@Entity
data class RemoteKey(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val nextPage: Int?,
val fetchedAt: Long
)
Tracks where to load from next. For cursor-based APIs, store the
nextCursor string instead.
Transforms
PagingData<T> supports transforms — apply between source and UI:
val users: Flow<PagingData<UserUiModel>> = repository.pager()
.map { paging ->
paging
.map { user -> user.toUiModel() } // T → R
.filter { user -> !user.isBlocked } // filter items
.insertSeparators { before, after -> // headers
if (before == null || before.firstLetter != after?.firstLetter) {
HeaderUiModel(letter = after?.firstLetter ?: '?')
} else null
}
}
.cachedIn(viewModelScope)
sealed interface UiModel {
data class UserItem(val user: User) : UiModel
data class Header(val letter: Char) : UiModel
}
In Compose:
items(count = users.itemCount, key = users.itemKey {
when (it) {
is UserItem -> "user-${it.user.id}"
is Header -> "header-${it.letter}"
}
}) { index ->
when (val item = users[index]) {
is UserItem -> UserRow(item.user)
is Header -> HeaderRow(item.letter)
null -> ItemPlaceholder()
}
}
insertSeparators is how to group lists alphabetically, by date, or any
separator logic.
Refresh and retry
@Composable
fun PagedList(viewModel: SearchViewModel = hiltViewModel()) {
val items = viewModel.items.collectAsLazyPagingItems()
val refreshing = items.loadState.refresh is LoadState.Loading
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { items.refresh() }
) {
LazyColumn { /* ... */ }
}
// Programmatic retry
Button(onClick = { items.retry() }) { Text("Retry") }
}
items.refresh()— reload from the topitems.retry()— re-attempt the last failed load
Headers, footers, separators
LazyColumn {
// Static header above all items
item { ListHeader() }
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
items[index]?.let { ProductRow(it) }
}
// Footer based on append state
when (items.loadState.append) {
is LoadState.Loading -> item { LoadingFooter() }
is LoadState.Error -> item { ErrorFooter { items.retry() } }
else -> if (items.loadState.append.endOfPaginationReached && items.itemCount > 0) {
item { EndOfListFooter() }
}
}
}
Placeholders — show item count before loading
PagingConfig(
pageSize = 20,
enablePlaceholders = true, // requires PagingSource to return itemsBefore / itemsAfter
initialLoadSize = 40
)
With placeholders enabled, items[index] returns null for not-yet-
loaded items, and the list shows the correct total size from the start.
Useful for jump-to-index UX (scrollbar, search-result counts).
Requires the API to return total count:
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
val response = api.search(query, page, pageSize = params.loadSize)
return LoadResult.Page(
data = response.users,
prevKey = ...,
nextKey = ...,
itemsBefore = (page - 1) * params.loadSize,
itemsAfter = response.totalCount - (page * params.loadSize)
)
}
getRefreshKey — restore scroll position
override fun getRefreshKey(state: PagingState<Int, User>): Int? =
state.anchorPosition?.let { anchor ->
val closestPage = state.closestPageToPosition(anchor)
closestPage?.prevKey?.plus(1) ?: closestPage?.nextKey?.minus(1)
}
On invalidation (e.g., user pulls to refresh), Paging calls
getRefreshKey to find which page to start from. Implementing this lets
users keep their scroll position across refresh.
Search and filtering
@HiltViewModel
class SearchViewModel @Inject constructor(
private val api: SearchApi
) : ViewModel() {
private val _query = MutableStateFlow("")
@OptIn(FlowPreview::class)
val results: Flow<PagingData<SearchResult>> = _query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
if (query.length < 2) flowOf(PagingData.empty())
else Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { SearchPagingSource(api, query) }
).flow
}
.cachedIn(viewModelScope)
fun onQueryChanged(query: String) { _query.value = query }
}
flatMapLatest cancels the previous Pager when the query changes —
no leftover requests from "pix" once the user types "pixel".
Filter without invalidating
For filters that don't change the data source, use filter:
val filtered: Flow<PagingData<Product>> = repository.pager()
.map { paging ->
paging.filter { product -> product.category in selectedCategories }
}
.cachedIn(viewModelScope)
If selectedCategories changes, cachedIn invalidates the chain and
re-applies the filter — no network refetch.
Invalidating
class UserRepository @Inject constructor(
private val db: AppDatabase
) {
private val _refreshSignal = MutableSharedFlow<Unit>(replay = 0)
fun invalidate() = scope.launch { _refreshSignal.emit(Unit) }
fun pager(): Flow<PagingData<User>> = _refreshSignal
.onStart { emit(Unit) }
.flatMapLatest {
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { db.userDao().pagingSource() }
).flow
}
.cachedIn(scope)
}
// Trigger from any write
suspend fun blockUser(id: String) {
db.userDao().setBlocked(id, true)
// Room auto-invalidates the PagingSource attached to the query
}
Room's pagingSource() auto-invalidates when the underlying table
changes — your writes propagate to the UI automatically.
For network-only PagingSource, call invalidate() manually or expose a
refresh signal as above.
Testing
@Test
fun `loads first page`() = runTest {
val source = TestSource()
val pager = TestPager(PagingConfig(pageSize = 3), source)
val result = pager.refresh() as LoadResult.Page<Int, String>
assertEquals(listOf("a", "b", "c"), result.data)
}
@Test
fun `flow emits paged data`() = runTest {
val items = (1..20).map { TestItem("id-$it") }
val pager = Pager(PagingConfig(pageSize = 5)) {
InMemoryPagingSource(items)
}
pager.flow
.asSnapshot { scrollTo(index = 10) }
.let { snapshot ->
assertEquals(11, snapshot.size)
}
}
paging-testing provides:
TestPager— drive load operations manuallyasSnapshot { }— collect current paging state from a Flow
Performance tips
pageSize and prefetchDistance
PagingConfig(
pageSize = 20, // items per page
prefetchDistance = 5, // start loading next page when 5 items from end
initialLoadSize = 40, // first load — usually 2x pageSize
maxSize = 200, // optional cap on in-memory items
enablePlaceholders = false
)
- Small
pageSize(10-20) = more frequent loads, lower memory - Large
prefetchDistance= smoother scrolling but more memory maxSize= drop old pages when scrolling forward; prevents OOM
Compose key is critical
items(
count = items.itemCount,
key = items.itemKey { it.id }, // ← stable identity
contentType = items.itemContentType { "product" }
) { index -> /* ... */ }
Without key, every reorder / insert recomposes the entire visible
range. With key, only the affected items re-render.
@Immutable data classes
@Immutable
data class Product(val id: String, val name: String, val price: Money)
Compose skips composables whose stable inputs haven't changed. Mark
your paged item types @Immutable for free skip-recomposition wins.
Common anti-patterns
Paging mistakes
- Forgetting .cachedIn(viewModelScope) — scroll resets
- No key in items() — recomposes everything on update
- PagingSource per Composable (rebuilt on every recomp)
- flatMapLatest without debounce on text input
- enablePlaceholders without itemsBefore / itemsAfter
- Catching exceptions outside load() — Paging needs LoadResult.Error
Production paging
- Always cachedIn at the ViewModel boundary
- itemKey for stable identity
- pagingSourceFactory creates source; Pager stays stable
- debounce + distinctUntilChanged before flatMapLatest
- Placeholders only if API returns total count
- Return LoadResult.Error(e); Paging handles retry
Key takeaways
Practice exercises
- 01
Network-only paging
Build a UserPagingSource that hits a paginated REST endpoint. Wire to a Compose LazyColumn with loading + error footers.
- 02
Local-first with RemoteMediator
Add RemoteMediator backed by Room. Show cached items while network refreshes. Test offline (airplane mode).
- 03
insertSeparators headers
Group items alphabetically with insertSeparators. Verify smooth scroll across letter boundaries.
- 04
Debounced search
Build a search ViewModel with debounce(300) + distinctUntilChanged + flatMapLatest. Confirm typing fast doesn't spam the API.
- 05
Pull-to-refresh
Add PullToRefreshBox + items.refresh(). Test that scroll position restores via getRefreshKey().
Next
Continue to Image Loading (Coil + Glide) for media patterns or return to Module 06 Overview.