---
title: "Paging 3 with Compose: Infinite Lists Done Right"
description: "Efficient infinite scrolling with Paging 3: RemoteMediator for offline-first pagination, Compose LazyColumn integration, and loading and error state handling."
url: https://android-x.com/blog/paging3-compose-infinite-lists
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-03-27
updated: 2026-10-11
category: "Data Layer"
reading_time: "13 min read"
---

# 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. Published 2026-03-27, updated 2026-10-11. Canonical page: https://android-x.com/blog/paging3-compose-infinite-lists

## Why does Paging 3 exist?

Loading a whole dataset does not scale. Take a feed of 10,000 posts at 5 KB each: that is 50 MB held in memory for a list the user will scroll a fraction of.

The [Paging library](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) loads the list a page at a time, and the documentation names what you get: "In-memory caching for your paged data", "Built-in request deduplication, which helps ensure that your app uses network bandwidth and system resources efficiently", and "Built-in support for error handling, including refresh and retry capabilities". The current stable release is 3.5.2 ([releases](https://developer.android.com/jetpack/androidx/releases/paging)).

"The Paging library's components operate in three layers of your app": the repository layer, where a `PagingSource` loads pages and a `RemoteMediator` can sit between network and database; the ViewModel layer, where a `Pager` produces a `Flow` of `PagingData`; and the UI layer, where Compose collects it.

This article was checked against the documentation in October 2026 and four things changed. The earlier version said Paging evicts off-screen pages. By default it does not. It said `RemoteMediator.load()` runs inside a Room transaction. It does not. Its `PagingSource` sample loaded the second page twice. And its FAQ gave a rule about lists over 100 items that had no source. Each is corrected where it comes up below.

## PagingSource: Loading Pages from an API

A [`PagingSource`](https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data) loads one page. It receives `LoadParams` with a key and returns a `LoadResult.Page` holding the items and the keys of the pages before and after.

The bug in the earlier sample is worth understanding, because it is easy to write. It passed `params.loadSize` to the API as the limit. But the first load asks for more than one page: `initialLoadSize` is "typically larger than pageSize", and its default is a multiple of it ([Android](https://developer.android.com/reference/kotlin/androidx/paging/PagingConfig)). With a page size of 20 and an initial load of 40, the sample fetched page 1 with a limit of 40, received items 1 to 40, then fetched page 2 with a limit of 20 and received items 21 to 40 a second time.

With a page-number API the fix is to keep the limit constant, which is what the documentation's own sample does by ignoring `loadSize`. That is allowed: "It is valid for PagingSource.load to return a LoadResult that has a different number of items than the requested load size" ([Android](https://developer.android.com/reference/kotlin/androidx/paging/PagingSource.LoadParams)).

Catch `IOException` and `HttpException` and return `LoadResult.Error`. A broad `catch (e: Exception)` also catches coroutine cancellation and turns it into an error row on screen. The reasoning is in the [coroutines deep dive](https://android-x.com/blog/kotlin-coroutines-deep-dive).

```kotlin
private const val NETWORK_PAGE_SIZE = 20

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

        return try {
            val response = api.getArticles(
                query = query,
                page = page,
                // Constant limit. Passing params.loadSize here makes
                // page 2 overlap an initial load of two pages.
                limit = NETWORK_PAGE_SIZE
            )

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

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

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
)
```

## RemoteMediator: Offline-First Pagination

A `PagingSource` has one data source. For a list that works without a network you add a [`RemoteMediator`](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db), and the division of labour is strict. The mediator "helps load paged data from the network into the database, but doesn't load data directly into the UI. Instead, the app uses the database as the source of truth". The screen only ever shows what is in Room. This is the same principle as [offline-first architecture](https://android-x.com/blog/offline-first-android-architecture), applied to a list.

Paging calls `load()` with a `LoadType` of `REFRESH`, `APPEND` or `PREPEND`. Your code fetches the page, writes it to the database, and reports whether the end was reached. To know which page comes next you store it: "remote keys allow you to save information about the most recent page requested from the server".

The transaction is yours to open. The earlier version listed, as a takeaway, that `load()` runs inside a Room transaction. It does not. The documentation's sample wraps its writes in `database.withTransaction { }` by hand, and for good reason: on a refresh you clear the old rows and insert the new ones, and a failure between those two steps would leave the user with an empty list.

That sample is written against Room 2.x. Room 3 replaces the transaction API with `withWriteTransaction` ([Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3)); the wider differences are in the [Room migrations guide](https://android-x.com/blog/room-database-migrations).

```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   // This API only pages forward
            )
            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.isEmpty()

            // Room 2.x. You open the transaction; Paging does not.
            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: Room generates the PagingSource
@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: which page to ask the server for next
@Entity(tableName = "remote_keys")
data class RemoteKey(
    @PrimaryKey val id: String,
    val nextPage: Int?
)
```

## Wiring It Together: Pager and ViewModel

A `Pager` joins the pieces and exposes `Flow<PagingData<T>>`. Finish the chain with `cachedIn(viewModelScope)`: "The cachedIn operator makes the data stream shareable and caches the loaded data with the provided CoroutineScope", and without it the data "cannot be recollected on".

`PagingConfig` is where the memory behaviour is decided, and its defaults are not what most people assume ([Android](https://developer.android.com/reference/kotlin/androidx/paging/PagingConfig)):

| Parameter | Default | What it does |
|---|---|---|
| `prefetchDistance` | `pageSize` | "defines how far from the edge of loaded content an access must be to trigger further loading" |
| `initialLoadSize` | A multiple of `pageSize` | The size of the first load |
| `enablePlaceholders` | `true` | Whether `null` items stand in for content not yet loaded |
| `maxSize` | `MAX_SIZE_UNBOUNDED` | "the maximum number of items that may be loaded into PagingData before pages should be dropped" |

The last row is the correction. Out of the box nothing is evicted: "When maxSize is set to MAX_SIZE_UNBOUNDED, the maximum number of items loaded is unbounded, and pages will never be dropped." A user who scrolls through 10,000 items holds 10,000 items. If you want the bound, set `maxSize`, and know its floor: "Pages are never dropped if they are within a prefetch window (defined to be pageSize + (2 * prefetchDistance)) of the most recent load."

The earlier version also recommended a prefetch distance of 3 to 5 items. That number had no source. The documented default is a full page.

A search screen needs a new `Pager` per query. Build it from the query with `flatMapLatest` so the previous one is cancelled, and cache the result once.

```kotlin
@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val api: ArticleApi,
    private val db: AppDatabase
) : ViewModel() {

    // Offline-first: RemoteMediator plus the Room PagingSource
    @OptIn(ExperimentalPagingApi::class)
    val articles: Flow<PagingData<ArticleEntity>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 20,       // The default, stated explicitly
            initialLoadSize = 40,
            maxSize = 200,               // Without this, nothing is dropped
            enablePlaceholders = false
        ),
        remoteMediator = ArticleRemoteMediator(api, db),
        pagingSourceFactory = { db.articleDao().pagingSource() }
    ).flow.cachedIn(viewModelScope)

    // Network-only search: one Pager per query, cached once
    private val query = MutableStateFlow("")

    @OptIn(ExperimentalCoroutinesApi::class)
    val searchResults: Flow<PagingData<ArticleDto>> = query
        .flatMapLatest { q ->
            Pager(
                config = PagingConfig(pageSize = 20),
                pagingSourceFactory = { ArticlePagingSource(api, q) }
            ).flow
        }
        .cachedIn(viewModelScope)

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

## Compose Integration: LazyColumn with Loading States

In Compose, "we can use the collectAsLazyPagingItems() extension function, and then pass in the returned LazyPagingItems to items() in our LazyColumn" ([Android](https://developer.android.com/develop/ui/compose/lists)). Give each item a stable key with `itemKey`; item animations depend on it.

Loading, error and idle are values, not callbacks. A `LoadState` is `Loading`, `Error` or `NotLoading`, and you "Access these states through the loadState property of your LazyPagingItems wrapper" ([Android](https://developer.android.com/topic/libraries/architecture/paging/load-state)). `loadState.refresh` describes the first load and `loadState.append` the next page at the bottom, so one `when` per position covers the screen.

One warning from the documentation applies as soon as a `RemoteMediator` is involved: "make sure to provide realistically sized placeholder items." The mediator keeps being invoked until the screen is full, so an item that renders at zero height asks the server for page after page.

```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)
    ) {
        // First load
        when (val refresh = articles.loadState.refresh) {
            is LoadState.Loading -> {
                item {
                    Box(Modifier.fillParentMaxSize(), Alignment.Center) {
                        CircularProgressIndicator()
                    }
                }
            }
            is LoadState.Error -> {
                item {
                    ErrorRetryCard(
                        message = refresh.error.localizedMessage ?: "Failed to load",
                        onRetry = { articles.retry() }
                    )
                }
            }
            is LoadState.NotLoading -> {
                if (articles.itemCount == 0) {
                    item { EmptyState(message = "No articles found") }
                }
            }
        }

        items(
            count = articles.itemCount,
            key = articles.itemKey { it.id }
        ) { index ->
            val article = articles[index]
            if (article != null) {
                ArticleCard(
                    article = article,
                    modifier = Modifier.animateItem()
                )
            } else {
                // Only reached when placeholders are enabled.
                // Give it the height of a real card.
                ArticlePlaceholder()
            }
        }

        // Next page, at the 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") }
    }
}
```

## Key takeaways

- Paging loads a list a page at a time. By default it never drops loaded pages: set PagingConfig.maxSize if you want memory bounded.
- With a page-number API keep the request limit constant. Passing params.loadSize makes page 2 overlap a larger initial load.
- RemoteMediator writes network pages into Room, and the UI reads only from Room. The database is the source of truth.
- RemoteMediator.load() does not run in a transaction. Open one yourself around the clear and the insert.
- prefetchDistance defaults to pageSize and enablePlaceholders defaults to true.
- Always finish the Pager flow with cachedIn(viewModelScope), or the data cannot be collected again.

## Frequently asked questions

### Should I use Paging 3 or write pagination by hand?

Use Paging when the list is long enough that loading it whole is wasteful. The library provides in-memory caching, request deduplication, and built-in refresh and retry, all of which a hand-written solution has to reproduce. Writing it yourself is reasonable only for a short list that fits comfortably in one request.

### How do I update an item in a paged list?

With a RemoteMediator, write the change to the Room table. The database is the source of truth, and a write invalidates the current PagingData so Paging presents the updated rows. With a network-only PagingSource there is no table to write to, so call refresh() on the LazyPagingItems to load the data again.

### Does Paging 3 free memory as the user scrolls?

Not by default. PagingConfig.maxSize defaults to MAX_SIZE_UNBOUNDED, and the reference states that pages will then never be dropped. Set maxSize to bound the list. Pages inside the prefetch window, defined as pageSize plus twice prefetchDistance, are never dropped, so maxSize must leave room for that window.

## Related articles

- [Offline-First Android Architecture with Room and WorkManager](https://android-x.com/blog/offline-first-android-architecture.md)
- [Room Database Migrations: A Survival Guide](https://android-x.com/blog/room-database-migrations.md)
- [Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions](https://android-x.com/blog/jetpack-compose-performance.md)
