Paging 3 with Compose: Infinite Lists Done Right

Implement efficient infinite scrolling with Paging 3, RemoteMediator, and Compose LazyColumn integration.

Introduction

Paging 3 loads and refreshes data efficiently from large datasets. It integrates with Compose LazyColumn for smooth infinite scrolling. This guide covers Paging 3 setup, RemoteMediator for offline-first, and Compose integration.

Paging 3 Architecture

PagingSource loads data pages from local database. RemoteMediator syncs local and remote data. Pager creates PagingData flow. ViewModel exposes PagingData to UI. Compose collects with lazyPagingItems.

Room Integration

Use @Query with PagingSource return type. Room provides efficient key-based pagination. Combine with RemoteMediator for network sync. Database is source of truth.

RemoteMediator Implementation

Implement load() for prepend/append/refresh. Return MediatorResult with endOfPagination. Handle network errors gracefully. Update local database on success.

Compose LazyColumn Integration

Use collectAsLazyPagingItems(). Pass items to LazyColumn. Handle loading states with when. Implement pull-to-refresh with rememberPullRefreshState.

Frequently Asked Questions

Paging 3 vs manual pagination?

Paging 3 handles caching, deduplication, and error recovery automatically. Manual pagination requires implementing all edge cases. Use Paging 3 for any list with more than 100 items.

How do I handle item updates?

Paging 3 invalidates and refreshes automatically when database changes. Use Flow to observe data changes. RemoteMediator handles server-side updates.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:31
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-PAGINGPUBLISHED
Data LayerMar 27, 202613 MIN

Paging 3 with Compose: Infinite Lists Done Right

Efficient infinite scrolling with Paging 3: RemoteMediator for offline-first pagination, Compose LazyColumn integration, and loading and error state handling.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Paging 3 Exists: The Memory and UX Problem

Loading a full dataset into memory doesn't scale. A social feed with 10,000 posts at 5KB each consumes 50MB of RAM just for the data -- before accounting for images, view holders, or Compose state. Users scroll through a fraction of this content, making the rest wasted memory that triggers garbage collection pauses and OOM crashes. Paging 3 solves this by loading data incrementally in pages, evicting off-screen pages from memory, and providing reactive APIs that integrate directly with Compose's LazyColumn. It handles the common complexities that developers otherwise reinvent poorly: loading indicators, error retry, placeholder items, and caching. The library consists of three layers as described in the Paging 3 overview: `PagingSource` (loads a single page from one data source), `RemoteMediator` (coordinates between network and database for offline-first pagination), and `Pager` (produces a `Flow<PagingData<T>>` that the UI collects). In Compose, `collectAsLazyPagingItems()` bridges PagingData directly into a `LazyColumn`.

PagingSource: Loading Pages from an API

A `PagingSource` defines how to load a single page of data. It receives a `LoadParams` object containing the page key and requested load size, and returns a `LoadResult` with the items, the previous page key, and the next page key. The key insight is that `PagingSource` handles both keyed pagination (cursor-based APIs) and offset-based pagination (page number + size). The key type is generic -- use `Int` for page numbers, `String` for cursor tokens, or any custom type.
kotlin
class ArticlePagingSource(
    private val api: ArticleApi,
    private val query: String?
) : PagingSource<Int, ArticleDto>() {

    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, ArticleDto> {
        val page = params.key ?: 1
        val pageSize = params.loadSize

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

            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.items.size < pageSize) null else page + 1
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, ArticleDto>): Int? {
        // Return the page key closest to the most recently accessed position
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
    }
}

// API interface
interface ArticleApi {
    @GET("articles")
    suspend fun getArticles(
        @Query("q") query: String?,
        @Query("page") page: Int,
        @Query("limit") limit: Int
    ): PaginatedResponse<ArticleDto>
}

@Serializable
data class PaginatedResponse<T>(
    val items: List<T>,
    val page: Int,
    val totalPages: Int,
    @SerialName("has_more") val hasMore: Boolean
)

RemoteMediator: Offline-First Pagination

`PagingSource` alone only works with a single data source. For offline-first apps, you need `RemoteMediator` -- a class that coordinates between the network API (remote) and Room database (local). The Pager reads from Room (fast, works offline), while RemoteMediator handles fetching new pages from the API and writing them to Room. When the user scrolls near the end of cached data, Paging triggers `RemoteMediator.load()` with `LoadType.APPEND`. When the user pulls to refresh, it triggers `LoadType.REFRESH`. The mediator fetches from the API, writes to Room, and the PagingSource (backed by Room) automatically emits the updated data.
kotlin
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {

    private val articleDao = db.articleDao()
    private val remoteKeyDao = db.remoteKeyDao()

    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   // No prepend for this API
            )
            LoadType.APPEND -> {
                val remoteKey = remoteKeyDao.getKey("articles")
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
                remoteKey.nextPage
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
        }

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

            val endReached = response.items.size < state.config.pageSize

            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    articleDao.clearAll()
                    remoteKeyDao.clearAll()
                }

                articleDao.insertAll(response.items.map { it.toEntity() })
                remoteKeyDao.insert(
                    RemoteKey(
                        id = "articles",
                        nextPage = if (endReached) null else page + 1
                    )
                )
            }

            MediatorResult.Success(endOfPaginationReached = endReached)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}

// Room DAO with PagingSource factory
@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY published_at DESC")
    fun pagingSource(): PagingSource<Int, ArticleEntity>

    @Upsert
    suspend fun insertAll(articles: List<ArticleEntity>)

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

// Remote key tracking for pagination state
@Entity(tableName = "remote_keys")
data class RemoteKey(
    @PrimaryKey val id: String,
    val nextPage: Int?
)

Wiring It Together: Pager and ViewModel

The `Pager` connects your PagingSource (or RemoteMediator + Room PagingSource) to produce a `Flow<PagingData<T>>`. The ViewModel exposes this flow, and the Compose layer collects it with `collectAsLazyPagingItems()`. For network-only pagination, pass a `PagingSource` factory. For offline-first pagination, pass both a `RemoteMediator` and a Room-backed `PagingSource`. The Pager handles coordination automatically.
kotlin
@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val api: ArticleApi,
    private val db: AppDatabase
) : ViewModel() {

    // Offline-first: RemoteMediator + Room PagingSource
    @OptIn(ExperimentalPagingApi::class)
    val articles: Flow<PagingData<ArticleEntity>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 5,         // Start loading 5 items before the end
            initialLoadSize = 40,        // First page loads 2x for fast initial render
            maxSize = 200,               // Evict pages beyond 200 items
            enablePlaceholders = false
        ),
        remoteMediator = ArticleRemoteMediator(api, db),
        pagingSourceFactory = { db.articleDao().pagingSource() }
    ).flow.cachedIn(viewModelScope)

    // Network-only (simpler, no offline support)
    fun searchArticles(query: String): Flow<PagingData<ArticleDto>> = Pager(
        config = PagingConfig(pageSize = 20),
        pagingSourceFactory = { ArticlePagingSource(api, query) }
    ).flow.cachedIn(viewModelScope)
}

Compose Integration: LazyColumn with Loading States

The `collectAsLazyPagingItems()` extension bridges PagingData into Compose. It returns a `LazyPagingItems` object that works directly with `LazyColumn`'s `items()` function. The object also exposes `loadState` for showing loading spinners, error messages, and retry buttons at the top (refresh), bottom (append), and before any content (initial load). This is where Paging 3 truly shines in Compose: the loading, error, and empty states are first-class citizens, not afterthoughts bolted onto a RecyclerView adapter.
kotlin
@Composable
fun ArticleListScreen(
    viewModel: ArticleListViewModel = hiltViewModel()
) {
    val articles = viewModel.articles.collectAsLazyPagingItems()

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        // Initial loading state
        when (articles.loadState.refresh) {
            is LoadState.Loading -> {
                item {
                    Box(Modifier.fillParentMaxSize(), Alignment.Center) {
                        CircularProgressIndicator()
                    }
                }
            }
            is LoadState.Error -> {
                val error = articles.loadState.refresh as LoadState.Error
                item {
                    ErrorRetryCard(
                        message = error.error.localizedMessage ?: "Failed to load",
                        onRetry = { articles.retry() }
                    )
                }
            }
            is LoadState.NotLoading -> {
                if (articles.itemCount == 0) {
                    item {
                        EmptyState(message = "No articles found")
                    }
                }
            }
        }

        // Article items
        items(
            count = articles.itemCount,
            key = articles.itemKey { it.id }
        ) { index ->
            val article = articles[index]
            if (article != null) {
                ArticleCard(
                    article = article,
                    modifier = Modifier.animateItem()
                )
            } else {
                ArticlePlaceholder()    // Shimmer placeholder
            }
        }

        // Append loading / error at bottom
        when (articles.loadState.append) {
            is LoadState.Loading -> {
                item {
                    Box(Modifier.fillMaxWidth().padding(16.dp), Alignment.Center) {
                        CircularProgressIndicator(Modifier.size(24.dp))
                    }
                }
            }
            is LoadState.Error -> {
                item {
                    TextButton(
                        onClick = { articles.retry() },
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text("Load more failed. Tap to retry.")
                    }
                }
            }
            else -> {}
        }
    }
}

@Composable
private fun ErrorRetryCard(message: String, onRetry: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth().padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text(message, style = MaterialTheme.typography.bodyLarge)
        Button(onClick = onRetry) { Text("Retry") }
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Paging 3 loads data incrementally and evicts off-screen pages, preventing memory exhaustion on large datasets.
  2. 2PagingSource handles single-source pagination (API or database). RemoteMediator coordinates network + Room for offline-first infinite scrolling.
  3. 3PagingConfig.prefetchDistance controls how early the next page loads -- set it to 3-5 items for seamless scrolling without visible loading pauses.
  4. 4collectAsLazyPagingItems() bridges PagingData into Compose with first-class loading, error, and empty states via loadState properties.
  5. 5Always use cachedIn(viewModelScope) to survive configuration changes without re-fetching data from the beginning.
  6. 6RemoteMediator.load() runs inside a Room transaction for atomic database updates, preventing partial page writes on network failures.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Paging 3 vs manual pagination?

Paging 3 handles caching, deduplication, and error recovery automatically. Manual pagination requires implementing all edge cases. Use Paging 3 for any list with more than 100 items.

How do I handle item updates?

Paging 3 invalidates and refreshes automatically when database changes. Use Flow to observe data changes. RemoteMediator handles server-side updates.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA