Your app shows a list of articles. There are 10,000 of them on the server. Loading all 10,000 at once would take forever, use tons of memory, and crush the user’s data plan. The solution: load them in pages. Show the first 20, and when the user scrolls near the bottom, load the next 20. And the next 20 after that. This is pagination, and Paging 3 is Jetpack’s library for doing it correctly — with loading states, error handling, caching, and Compose integration built in.


The Mental Model — How Pagination Works

// Think of it like reading a BOOK one page at a time:
//
// PAGE 1: Articles 1-20    ← loaded first (user sees these immediately)
// PAGE 2: Articles 21-40   ← loaded when user scrolls near bottom of page 1
// PAGE 3: Articles 41-60   ← loaded when user scrolls near bottom of page 2
// ... and so on
//
// The user never waits for all 10,000 articles
// They see the first page instantly, and more load seamlessly as they scroll
//
// THREE components in Paging 3:
//
// 1. PagingSource — WHERE the data comes from (API, database)
//    "Give me page 2 of the articles"
//
// 2. Pager — the ENGINE that requests pages from PagingSource
//    "User scrolled near the end → time to load the next page"
//
// 3. PagingData — the RESULT that the UI displays
//    A lazy stream of items that loads more as the user scrolls
//
//  PagingSource ──→ Pager ──→ PagingData ──→ LazyColumn
//  (loads pages)    (engine)   (items stream)  (displays)

// Two pagination strategies:
//
// NETWORK ONLY: load pages directly from API
//   Good for: simple lists, real-time data, search results
//   PagingSource talks to Retrofit directly
//
// NETWORK + DATABASE (offline-first):
//   Good for: production apps that work offline
//   RemoteMediator loads from API → saves to Room
//   PagingSource loads from Room → displays in UI
//   User sees cached data immediately, fresh data loads in background

Setup

// build.gradle.kts
dependencies {
    implementation("androidx.paging:paging-runtime-ktx:3.3.2")
    // paging-runtime-ktx — core Paging library with Kotlin extensions

    implementation("androidx.paging:paging-compose:3.3.2")
    // paging-compose — Compose integration (LazyColumn + PagingData)

    // Optional: for Room integration
    implementation("androidx.room:room-paging:2.6.1")
    // room-paging — Room PagingSource for database-backed pagination

    // For testing:
    testImplementation("androidx.paging:paging-testing:3.3.2")
}

Strategy 1: Network-Only Pagination

The simplest approach — load pages directly from the API. Good for search results, feeds, and data that doesn’t need offline support.

Step 1: Define the API endpoint

// The API needs to support pagination — typically with page/limit or offset/limit
interface ArticleApi {
    @GET("articles")
    suspend fun getArticles(
        @Query("page") page: Int,       // which page to load
        @Query("limit") limit: Int = 20  // how many items per page
    ): ArticleListResponse
}

data class ArticleListResponse(
    @SerializedName("data") val articles: List<ArticleDto>,
    @SerializedName("total") val total: Int,
    @SerializedName("page") val currentPage: Int,
    @SerializedName("total_pages") val totalPages: Int
)

// Other pagination patterns:
// Cursor-based: @Query("cursor") cursor: String?
//   Server returns: { data: [...], next_cursor: "abc123" }
//   Next page: ?cursor=abc123
// Offset-based: @Query("offset") offset: Int
//   Page 1: offset=0, Page 2: offset=20, Page 3: offset=40

Step 2: Create a PagingSource

class ArticlePagingSource(
    private val api: ArticleApi,
    private val query: String? = null   // optional search query
) : PagingSource<Int, ArticleDto>() {
    // PagingSource is an ABSTRACT CLASS from androidx.paging
    // Type parameters: Key (Int = page number), Value (ArticleDto = item type)
    // You implement TWO functions: load() and getRefreshKey()

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ArticleDto> {
        // load() is an ABSTRACT SUSPEND FUNCTION — called by the Pager
        // params is a LoadParams — a SEALED CLASS with info about what to load
        // params.key → the page number to load (null for first page)
        // params.loadSize → how many items to load

        val page = params.key ?: 1
        // First load: key is null → start at page 1

        return try {
            val response = api.getArticles(page = page, limit = params.loadSize)

            LoadResult.Page(
                // LoadResult is a SEALED CLASS from paging
                // LoadResult.Page means "here's the data, here are the next/prev keys"
                data = response.articles,
                // data — the list of items for this page

                prevKey = if (page == 1) null else page - 1,
                // prevKey — the key for the PREVIOUS page
                // null means "there's no previous page" (we're at the beginning)

                nextKey = if (page >= response.totalPages) null else page + 1
                // nextKey — the key for the NEXT page
                // null means "there's no next page" (we've reached the end)
                // When nextKey is null, Paging stops loading more pages
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
            // LoadResult.Error means "loading failed — show error state"
            // The exception is passed to the UI layer for display
        }
    }

    override fun getRefreshKey(state: PagingState<Int, ArticleDto>): Int? {
        // getRefreshKey() is an ABSTRACT FUNCTION — called when the list is REFRESHED
        // (pull-to-refresh, invalidation)
        // Should return the key to start loading from after refresh
        // PagingState is a CLASS that holds the current list state

        return state.anchorPosition?.let { anchorPosition ->
            // anchorPosition is the index of the item that was VISIBLE when refresh happened
            val anchorPage = state.closestPageToPosition(anchorPosition)
            // closestPageToPosition() is a FUNCTION on PagingState
            // Returns the LoadResult.Page closest to the anchor position
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
            // Calculate the page number around the anchor
        }
    }
}

// How load() is called:
// 1. First load: load(LoadParams.Refresh(key = null, loadSize = 60))
//    → loads first 3 pages worth of items (initial load is larger)
// 2. Scroll down: load(LoadParams.Append(key = 4, loadSize = 20))
//    → loads the next page
// 3. Scroll up: load(LoadParams.Prepend(key = 0, loadSize = 20))
//    → loads the previous page (if scrolled past the beginning)

Step 3: Create the Pager in ViewModel

@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val api: ArticleApi
) : ViewModel() {

    val articles: Flow<PagingData<ArticleDto>> = Pager(
        // Pager is a CLASS from androidx.paging
        // It's the ENGINE that drives pagination

        config = PagingConfig(
            // PagingConfig is a CLASS — configuration for the Pager
            pageSize = 20,
            // pageSize — how many items per page
            // This is passed as params.loadSize in load()

            initialLoadSize = 60,
            // initialLoadSize — how many items to load on FIRST request
            // Default is pageSize * 3 = 60
            // Larger initial load fills the screen + preloads next page

            prefetchDistance = 5,
            // prefetchDistance — how many items BEFORE the end to start loading next page
            // When user scrolls to item 15 of 20 → triggers loading page 2
            // Higher = smoother scrolling (loads earlier)
            // Lower = less data usage (loads later)

            enablePlaceholders = false
            // enablePlaceholders — show null placeholders for unloaded items?
            // false = list grows as pages load (simpler, recommended)
            // true = list has fixed size with nulls for unloaded items (requires total count)
        ),

        pagingSourceFactory = {
            // pagingSourceFactory — creates a new PagingSource for each refresh
            // Called on initial load AND every pull-to-refresh
            // MUST create a NEW instance each time (PagingSource is single-use)
            ArticlePagingSource(api)
        }
    ).flow
    // .flow is a PROPERTY on Pager — returns Flow<PagingData<T>>
    // PagingData is a CLASS that holds the paged items
    .cachedIn(viewModelScope)
    // cachedIn() is an EXTENSION FUNCTION on Flow<PagingData>
    // Caches the PagingData in the ViewModel
    // Without this: rotation recreates the entire list → re-fetches all pages!
    // With this: rotation reuses cached data → instant restore
}

Step 4: Display in Compose

@Composable
fun ArticleListScreen(viewModel: ArticleViewModel = hiltViewModel()) {

    val articles = viewModel.articles.collectAsLazyPagingItems()
    // collectAsLazyPagingItems() is an EXTENSION FUNCTION on Flow<PagingData>
    // from paging-compose
    // Returns LazyPagingItems<T> — a CLASS that integrates with LazyColumn
    // It handles: loading pages, tracking state, providing items

    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Display items
        items(
            count = articles.itemCount,
            // itemCount is a PROPERTY on LazyPagingItems — number of loaded items
            key = articles.itemKey { it.id }
            // itemKey is a FUNCTION on LazyPagingItems — provides stable keys
        ) { index ->
            val article = articles[index]
            // articles[index] returns the item at this position
            // May return null if using placeholders

            if (article != null) {
                ArticleCard(article = article)
            }
        }

        // Show loading indicator at the bottom while loading next page
        when (articles.loadState.append) {
            // loadState is a PROPERTY on LazyPagingItems
            // loadState.append is the state of loading the NEXT page
            // CombinedLoadStates has: refresh (first load), prepend, append (next page)

            is LoadState.Loading -> {
                // LoadState is a SEALED CLASS from paging
                // Loading = currently fetching data
                item {
                    CircularProgressIndicator(
                        modifier = Modifier.fillMaxWidth().wrapContentWidth()
                    )
                }
            }
            is LoadState.Error -> {
                // Error = loading failed
                val error = (articles.loadState.append as LoadState.Error).error
                item {
                    ErrorItem(
                        message = error.localizedMessage ?: "Error loading more",
                        onRetry = { articles.retry() }
                        // retry() is a FUNCTION on LazyPagingItems — retries the failed load
                    )
                }
            }
            is LoadState.NotLoading -> {
                // NotLoading = idle (either more pages available or end reached)
            }
        }

        // Show loading/error for initial load
        when (articles.loadState.refresh) {
            is LoadState.Loading -> {
                item {
                    Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
                        CircularProgressIndicator()
                    }
                }
            }
            is LoadState.Error -> {
                val error = (articles.loadState.refresh as LoadState.Error).error
                item {
                    Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
                        Column(horizontalAlignment = Alignment.CenterHorizontally) {
                            Text("Failed to load: ${error.localizedMessage}")
                            Button(onClick = { articles.refresh() }) {
                                // refresh() is a FUNCTION on LazyPagingItems — restarts from page 1
                                Text("Retry")
                            }
                        }
                    }
                }
            }
            is LoadState.NotLoading -> {
                if (articles.itemCount == 0) {
                    item {
                        Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
                            Text("No articles found")
                        }
                    }
                }
            }
        }
    }
}

Strategy 2: Network + Database (Offline-First)

The production approach — data is cached in Room, pages are loaded from the database, and RemoteMediator fetches from the network when needed:

// The flow:
//
// ┌──────────┐     ┌──────────────┐     ┌──────────┐     ┌──────────┐
// │ LazyCol  │←────│ PagingSource │←────│   Room   │←────│ Remote   │
// │ (UI)     │     │ (from Room)  │     │ Database │     │ Mediator │
// └──────────┘     └──────────────┘     └──────────┘     └──────────┘
//                                                              │
//                                                              ↓
//                                                        ┌──────────┐
//                                                        │  API     │
//                                                        │ (server) │
//                                                        └──────────┘
//
// 1. UI needs data → asks PagingSource
// 2. PagingSource reads from Room → shows cached data instantly
// 3. RemoteMediator fetches from API → writes to Room
// 4. Room PagingSource detects change → emits updated data → UI updates
//
// Benefits:
// ✅ Instant display (cached data)
// ✅ Works offline
// ✅ Single source of truth (Room)
// ✅ Background refresh

RemoteMediator

@OptIn(ExperimentalPagingApi::class)
// ExperimentalPagingApi is an ANNOTATION — RemoteMediator is still experimental
class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val database: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
    // RemoteMediator is an ABSTRACT CLASS from paging
    // Type params: Key (Int = page number), Value (ArticleEntity = database entity)
    // It coordinates between the network (API) and local storage (Room)

    private val dao = database.articleDao()

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, ArticleEntity>
    ): MediatorResult {
        // load() is called by Paging when it needs MORE data
        // loadType tells you WHAT kind of load:
        //   LoadType.REFRESH → first load or pull-to-refresh
        //   LoadType.PREPEND → load before the first item
        //   LoadType.APPEND  → load after the last item

        val page = when (loadType) {
            LoadType.REFRESH -> 1
            // Refresh: start from page 1

            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            // Prepend: we don't load backwards → signal "no more data before"
            // MediatorResult is a SEALED CLASS from paging
            // Success(endOfPaginationReached = true) → "stop looking for more data in this direction"

            LoadType.APPEND -> {
                // Calculate next page from current database state
                val lastItem = state.lastItemOrNull()
                    // lastItemOrNull() is a FUNCTION on PagingState
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
                // If no items in database → no next page
                
                // You need to track the current page somehow
                // Option 1: Store page number in a separate "remote_keys" table
                // Option 2: Calculate from item count
                (dao.getArticleCount() / state.config.pageSize) + 1
            }
        }

        return try {
            val response = api.getArticles(page = page, limit = state.config.pageSize)

            database.withTransaction {
                // withTransaction {} is an EXTENSION SUSPEND FUNCTION on RoomDatabase
                // Runs all operations atomically
                if (loadType == LoadType.REFRESH) {
                    dao.deleteAllArticles()
                    // Clear old data on refresh
                }
                dao.insertArticles(response.articles.map { it.toEntity() })
            }

            MediatorResult.Success(
                endOfPaginationReached = response.articles.isEmpty()
                // If server returned empty list → we've loaded all articles
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
            // Error → Paging shows error state in UI
        }
    }
}

Room PagingSource (generated automatically)

// Room can GENERATE a PagingSource directly from a @Query
// No custom PagingSource needed!

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY published_at DESC")
    fun getArticlesPagingSource(): PagingSource<Int, ArticleEntity>
    // Room generates the PagingSource implementation!
    // It knows how to load pages from the database
    // It automatically invalidates when the table changes
    // This is the SIMPLEST way to paginate from a database
    
    @Query("SELECT COUNT(*) FROM articles")
    suspend fun getArticleCount(): Int

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticles(articles: List<ArticleEntity>)

    @Query("DELETE FROM articles")
    suspend fun deleteAllArticles()
}

Wiring it all together in ViewModel

@OptIn(ExperimentalPagingApi::class)
@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val api: ArticleApi,
    private val database: AppDatabase
) : ViewModel() {

    val articles: Flow<PagingData<ArticleEntity>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5),

        remoteMediator = ArticleRemoteMediator(api, database),
        // remoteMediator parameter — connects the Pager to your RemoteMediator
        // When Paging needs more data:
        // 1. First asks Room PagingSource for cached data
        // 2. Then asks RemoteMediator to fetch from network and update Room
        // 3. Room PagingSource detects the update → emits new data

        pagingSourceFactory = {
            database.articleDao().getArticlesPagingSource()
            // Room-generated PagingSource — reads from database
            // RemoteMediator writes to database → PagingSource re-emits → UI updates
        }
    ).flow
    .cachedIn(viewModelScope)

    // The UI code is EXACTLY the same as Strategy 1
    // collectAsLazyPagingItems() works with both strategies!
}

Transforming PagingData

// You can transform PagingData items without breaking pagination

val articles: Flow<PagingData<Article>> = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { ArticlePagingSource(api) }
).flow
    .map { pagingData ->
        pagingData.map { dto ->
            // pagingData.map {} is a FUNCTION on PagingData — transforms each item
            dto.toDomain()
            // Convert DTO → Domain model inside the PagingData stream
        }
    }
    .map { pagingData ->
        pagingData.filter { article ->
            // pagingData.filter {} is a FUNCTION on PagingData — removes items
            article.isPublished
        }
    }
    .map { pagingData ->
        pagingData.insertSeparators { before, after ->
            // insertSeparators {} is a FUNCTION on PagingData
            // Inserts items BETWEEN existing items (for date headers, section dividers)
            // before = the item above, after = the item below
            // Return the separator to insert, or null for no separator

            if (before == null) {
                // First item → insert a header
                DateHeader(after?.formattedDate ?: "")
            } else if (before.formattedDate != after?.formattedDate) {
                // Date changed between items → insert date separator
                DateHeader(after?.formattedDate ?: "")
            } else {
                null   // same date → no separator
            }
        }
    }
    .cachedIn(viewModelScope)

Pull-to-Refresh

@Composable
fun ArticleListScreen(viewModel: ArticleViewModel = hiltViewModel()) {
    val articles = viewModel.articles.collectAsLazyPagingItems()

    val isRefreshing = articles.loadState.refresh is LoadState.Loading
    // Check if initial/refresh load is in progress

    SwipeRefresh(
        state = rememberSwipeRefreshState(isRefreshing),
        onRefresh = {
            articles.refresh()
            // refresh() is a FUNCTION on LazyPagingItems
            // Triggers a full reload from page 1
            // For network-only: creates a new PagingSource
            // For network+database: calls RemoteMediator with LoadType.REFRESH
        }
    ) {
        LazyColumn {
            items(
                count = articles.itemCount,
                key = articles.itemKey { it.id }
            ) { index ->
                articles[index]?.let { ArticleCard(it) }
            }
            // ... loading states (same as before)
        }
    }
}

Search with Pagination

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val api: ArticleApi
) : ViewModel() {

    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()

    val searchResults: Flow<PagingData<ArticleDto>> = _searchQuery
        .debounce(300)
        // debounce is an EXTENSION FUNCTION on Flow — waits 300ms after last emission
        // Prevents searching on every keystroke: "k" → "ko" → "kot" → "kotl" → "kotlin"
        // Only searches for "kotlin" (after 300ms of no typing)
        .filter { it.length >= 2 }
        // Don't search for 1-character queries
        .distinctUntilChanged()
        // Don't re-search if query hasn't changed
        .flatMapLatest { query ->
            // flatMapLatest is an EXTENSION FUNCTION on Flow
            // Cancels the previous Pager when a new query arrives
            // New query → cancel old pagination → start fresh pagination
            Pager(
                config = PagingConfig(pageSize = 20),
                pagingSourceFactory = { ArticlePagingSource(api, query = query) }
            ).flow
        }
        .cachedIn(viewModelScope)

    fun onQueryChanged(query: String) {
        _searchQuery.value = query
    }
}

Common Mistakes to Avoid

Mistake 1: Forgetting cachedIn(viewModelScope)

// ❌ Without cachedIn — rotation re-fetches ALL pages from scratch
val articles = Pager(...).flow
// Rotate screen → Flow restarts → pages 1, 2, 3 re-fetched → bad UX!

// ✅ With cachedIn — rotation reuses cached data
val articles = Pager(...).flow.cachedIn(viewModelScope)
// Rotate screen → cached PagingData is reused → instant restore

Mistake 2: Reusing PagingSource instances

// ❌ Same PagingSource instance on every refresh — crash
val pagingSource = ArticlePagingSource(api)   // created once
val articles = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { pagingSource }     // ❌ returns same instance!
).flow

// ✅ Create a NEW PagingSource each time — required by Paging
val articles = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { ArticlePagingSource(api) }   // ✅ new instance each time
).flow

Mistake 3: Not handling all three load states

// ❌ Only showing items — no loading spinner, no error handling
LazyColumn {
    items(articles.itemCount) { index ->
        articles[index]?.let { ArticleCard(it) }
    }
    // User sees nothing while loading!
    // User sees nothing when error occurs!
}

// ✅ Handle refresh (initial), append (next page), and empty states
// Show spinner for loadState.refresh == Loading
// Show error + retry for loadState.refresh == Error
// Show bottom spinner for loadState.append == Loading
// Show bottom error + retry for loadState.append == Error
// Show "empty" message when itemCount == 0 and NotLoading

Mistake 4: Using collectAsState instead of collectAsLazyPagingItems

// ❌ collectAsState doesn't understand PagingData
val articles by viewModel.articles.collectAsState(initial = PagingData.empty())
// No LazyColumn integration, no loading states, pagination doesn't work

// ✅ Use the dedicated Compose extension
val articles = viewModel.articles.collectAsLazyPagingItems()
// Proper LazyColumn integration, loading states, auto-page-loading

Mistake 5: Loading too many items initially

// ❌ initialLoadSize too large — slow first load, wasted data
PagingConfig(pageSize = 20, initialLoadSize = 200)
// Loads 200 items on first request — user only sees 10-15!

// ✅ initialLoadSize = 2-3x pageSize (default)
PagingConfig(pageSize = 20, initialLoadSize = 60)
// Loads 60 items — fills the screen + preloads a bit for smooth scrolling

Summary

  • Paging 3 loads data in pages as the user scrolls — efficient, smooth, handles loading/error states
  • PagingSource (abstract class) loads one page at a time — implement load() and getRefreshKey()
  • LoadResult.Page returns data + prevKey/nextKey; LoadResult.Error signals failure
  • Pager (class) is the engine — takes PagingConfig + pagingSourceFactory, produces Flow<PagingData>
  • PagingConfig (class) configures pageSize, initialLoadSize, prefetchDistance
  • cachedIn(viewModelScope) (extension function on Flow<PagingData>) caches data across rotation — always use this
  • collectAsLazyPagingItems() (extension function on Flow<PagingData>) integrates with LazyColumn in Compose
  • LazyPagingItems (class) provides itemCount, loadState, refresh(), retry()
  • LoadState (sealed class): Loading, Error, NotLoading — check loadState.refresh and loadState.append
  • RemoteMediator (abstract class, experimental) coordinates network + database for offline-first pagination
  • Room can generate PagingSource automatically from @Query functions returning PagingSource<Int, Entity>
  • Transform items with pagingData.map {}, filter {}, and insertSeparators {}
  • For search: use flatMapLatest to cancel old pagination when query changes
  • Always create a new PagingSource in pagingSourceFactory — PagingSource is single-use

Paging 3 handles the complexity of infinite scrolling so you don’t have to. Define a PagingSource, create a Pager, collect in Compose — and you get automatic page loading, loading spinners, error handling, and retry. For production apps, add RemoteMediator for offline support. The key insight: PagingSource loads the data, Pager manages the flow, LazyPagingItems displays it — each piece does one thing well.

Happy coding!