Skip to content

Paging 3

Data & AnalyticsDataKotlin

What it is

Paging 3 loads large datasets incrementally on Android, handling page requests, retries, placeholders and Compose integration.

A PagingSource loads one page given a key. Pager turns it into a Flow<PagingData>, and the UI layer collects it with built-in loading and error states.

Installation

implementation("androidx.paging:paging-runtime:3.3.2")

Getting started

The smallest useful thing you can do with it, and what each part means.

PagingSource and Compose
class BookPagingSource(private val api: BookApi) : PagingSource<Int, Book>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> = try {
        val page = params.key ?: 1
        val response = api.getBooks(page, params.loadSize)
        LoadResult.Page(
            data = response.books,
            prevKey = if (page == 1) null else page - 1,
            nextKey = if (response.books.isEmpty()) null else page + 1,
        )
    } catch (e: IOException) {
        LoadResult.Error(e)   // the UI can offer retry
    }

    override fun getRefreshKey(state: PagingState<Int, Book>) =
        state.anchorPosition?.let { state.closestPageToPosition(it)?.nextKey?.minus(1) }
}

@Composable
fun BookList(items: LazyPagingItems<Book>) {
    LazyColumn {
        items(items.itemCount, key = items.itemKey { it.id }) { index ->
            items[index]?.let { BookRow(it) }
        }
        when (items.loadState.append) {
            is LoadState.Loading -> item { CircularProgressIndicator() }
            is LoadState.Error   -> item { RetryButton(onClick = items::retry) }
            else -> Unit
        }
    }
}
Returning null for nextKey is how Paging learns it has reached the end. Getting that wrong causes either infinite loading or a list that stops early.

Advanced usage

Where the library earns its place over a simpler alternative.

Transformations and offline-first
val books: Flow<PagingData<UiBook>> = Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
    remoteMediator = BookRemoteMediator(api, database),   // network + local
    pagingSourceFactory = { database.bookDao().pagingSource() },
).flow
    .map { paging -> paging.map { it.toUiBook() } }
    .cachedIn(viewModelScope)   // survives configuration changes
cachedIn is essential — without it, rotating the device refetches every loaded page. RemoteMediator is what makes the list work offline: the database is the source of truth and the network fills it.

Errors and fixes

The failures you are most likely to hit, and what actually resolves them.

The list reloads on every rotation
cachedIn is missing from the Flow.
Duplicate items appear while scrolling
The paging keys overlap, usually an off-by-one in nextKey, or the server is not returning a stable order.

Best practices

  • Always call cachedIn(viewModelScope) or rotation refetches everything.
  • Return null for nextKey at the end, or the list loads forever.
  • Provide a stable itemKey so scroll position survives refreshes.
  • Use RemoteMediator with a local database for offline support rather than paging the network directly.

Background

Why it exists, and what it was reacting to.

Paging exists because loading a ten-thousand-row list at once exhausts memory and wastes bandwidth. Paging 3 rebuilt the API around coroutines and Flow after the first two versions proved awkward.