Lesson 73 of 83 advanced

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?

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

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. 1. ArticlePagingSource extends PagingSource — Int is the page key type, Article is the data type.
  2. 2. load() receives LoadParams containing the key (page number) and loadSize. We default to page 1 if key is null (initial load).
  3. 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. 4. getRefreshKey() determines which page to load on invalidation, using anchorPosition to find the closest page.
  5. 5. ArticleRemoteMediator handles LoadType.REFRESH (pull-to-refresh), PREPEND (load earlier), and APPEND (load more).
  6. 6. PREPEND returns Success with endOfPaginationReached=true since our feed only goes forward.
  7. 7. APPEND reads the next page number from a RemoteKey stored in Room. No key means we've reached the end.
  8. 8. db.withTransaction ensures clearing old data and inserting new data + remote keys happens atomically.
  9. 9. The ViewModel creates a Pager with PagingConfig, RemoteMediator, and a PagingSourceFactory from Room's DAO.
  10. 10. The flow is mapped to convert entities to domain models and cachedIn(viewModelScope) for configuration change survival.
  11. 11. In Compose, collectAsLazyPagingItems() bridges Flow to LazyColumn-compatible items.
  12. 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

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery