๐Ÿ”Œ APIs, Databases, and Backend Integration

Pagination with Paging 3

Loading thousands of items at once wastes data, memory and time. Paging 3 loads data page by page as the user scrolls, handles loading and error states, deduplicates requests, and works with Room for offline caching.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The pieces
  2. Setup
  3. 1. PagingSource
  4. 2. Pager and ViewModel
  5. 3. Compose UI with load states
  6. 4. Network + Room with RemoteMediator
  7. Testing
  8. Where Paging fits
  9. Common mistakes
  10. Frequently asked questions
  11. Conclusion

A hand-rolled infinite scroll has to track the current page, avoid duplicate requests while scrolling fast, show a footer spinner, handle errors with retry, reset on refresh, and survive rotation. Paging 3 handles all of this. You implement one function that loads a page.

The pieces

Paging 3 data flow A PagingSource loads pages from the API. A Pager in the repository creates a Flow of PagingData. The ViewModel caches it with cachedIn. Compose collects it as LazyPagingItems and LazyColumn requests more pages as the user scrolls. PagingSourceload(page) PagerFlow<PagingData> ViewModelcachedIn(scope) LazyPagingItemsLazyColumn scrolling near the end triggers the next load
The UI drives loading. Paging prefetches the next page before the user reaches the end.

Setup

app/build.gradle.ktsGradle KTS
dependencies {
    implementation("androidx.paging:paging-runtime:<paging-version>")
    implementation("androidx.paging:paging-compose:<paging-version>")
    testImplementation("androidx.paging:paging-testing:<paging-version>")
}

1. PagingSource

ArticlePagingSource.ktKotlin
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 items = api.search(query, page = page, pageSize = params.loadSize).map { it.toDomain() }
            LoadResult.Page(
                data = items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (items.isEmpty()) null else page + 1,
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
            LoadResult.Error(e)
        }
    }

    // Which page to load when the list is refreshed (e.g. after invalidation), based on scroll position
    override fun getRefreshKey(state: PagingState<Int, Article>): Int? =
        state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.let { it.prevKey?.plus(1) ?: it.nextKey?.minus(1) }
        }
}

If your API uses cursors instead of page numbers, use the cursor string as the key type.

2. Pager and ViewModel

SearchViewModel.ktKotlin
class SearchViewModel(private val api: ArticleApi) : ViewModel() {
    private val query = MutableStateFlow("android")

    @OptIn(ExperimentalCoroutinesApi::class)
    val results: Flow<PagingData<Article>> = query
        .flatMapLatest { q ->
            Pager(PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false)) {
                ArticlePagingSource(api, q)
            }.flow
        }
        .cachedIn(viewModelScope)        // survive rotation; share between collectors

    fun search(q: String) { query.value = q }
}

3. Compose UI with load states

SearchResults.ktKotlin
@Composable
fun SearchResults(viewModel: SearchViewModel) {
    val items = viewModel.results.collectAsLazyPagingItems()

    when (val refresh = items.loadState.refresh) {
        is LoadState.Loading -> if (items.itemCount == 0) { FullScreenLoading(); return }
        is LoadState.Error -> if (items.itemCount == 0) {
            FullScreenError(refresh.error.message ?: "Couldn't load", onRetry = items::retry); return
        }
        is LoadState.NotLoading -> if (items.itemCount == 0 && refresh.endOfPaginationReached) { EmptyState(); return }
    }

    LazyColumn {
        items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
            items[index]?.let { ArticleRow(it) }
        }
        when (val append = items.loadState.append) {
            is LoadState.Loading -> item { LoadingFooter() }
            is LoadState.Error -> item { RetryFooter(onRetry = items::retry) }
            else -> Unit
        }
    }
}

4. Network + Room with RemoteMediator

For offline support, the database becomes the source of truth. Room provides the PagingSource, and a RemoteMediator fetches pages from the network into Room when the local data runs out or needs refreshing:

ArticleRemoteMediator.kt (outline)Kotlin
@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 -> db.remoteKeysDao().lastKey()?.nextPage
                ?: return MediatorResult.Success(endOfPaginationReached = true)
        }
        return try {
            val response = api.list(page = page, pageSize = state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) { db.articleDao().clearAll(); db.remoteKeysDao().clear() }
                db.remoteKeysDao().insert(RemoteKey(nextPage = if (response.isEmpty()) null else page + 1))
                db.articleDao().insertAll(response.map { it.toEntity() })
            }
            MediatorResult.Success(endOfPaginationReached = response.isEmpty())
        } catch (e: IOException) {
            MediatorResult.Error(e)
        }
    }
}
// Pager(config, remoteMediator = ArticleRemoteMediator(api, db)) { db.articleDao().pagingSource() }

Testing

ArticlePagingSourceTest.ktKotlin
@Test
fun `loads first page and sets next key`() = runTest {
    val source = ArticlePagingSource(FakeArticleApi(totalItems = 45), query = "kotlin")
    val pager = TestPager(PagingConfig(pageSize = 20), source)
    val result = pager.refresh() as PagingSource.LoadResult.Page
    assertEquals(20, result.data.size)
    assertEquals(2, result.nextKey)
}

Where Paging fits

Search results

Network-only PagingSource, recreated per query with flatMapLatest.

Feeds with offline

RemoteMediator plus Room, so cached pages appear instantly and new pages load as needed.

Large local tables

Room's built-in PagingSource for chat history or logs with tens of thousands of rows.

Common mistakes

  • Forgetting cachedIn(viewModelScope), which reloads everything on rotation.
  • Transforming PagingData after cachedIn. Apply map and filter before caching.
  • Missing stable keys in the LazyColumn.
  • Unstable sort order on the server, causing duplicates or gaps between pages.

Frequently asked questions

Can I modify items, such as toggling a like, in paged data?

With RemoteMediator plus Room, update the database and the paged list updates automatically. For network-only paging, invalidate the source or overlay local state.

Should I enable placeholders?

Only if you know the total count and want stable scrollbar sizing. Most apps disable them.

Is Paging overkill for 100 items?

Usually yes. Load them at once into a LazyColumn. Paging shines with large or unbounded datasets.

Conclusion

Paging 3 gives you incremental loading, deduplication, load states and retry for the price of a PagingSource. Cache in the ViewModel, render with LazyPagingItems and stable keys, and add a RemoteMediator with Room for offline-first feeds.

Next step: add user accounts with Firebase Authentication in Android.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp