SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:26
UP00:01
MOD · ARTICLE · DATA LAYERS/N · AX-PAGINGPUBLISHED
All Articles
Data Layer · Updated 13 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 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 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). "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 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). 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). 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.
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, 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, 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); the wider differences are in the Room migrations guide.
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):
ParameterDefaultWhat it does
prefetchDistancepageSize"defines how far from the edge of loaded content an access must be to trigger further loading"
initialLoadSizeA multiple of pageSizeThe size of the first load
enablePlaceholderstrueWhether null items stand in for content not yet loaded
maxSizeMAX_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). 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). 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") }
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

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

Frequently Asked

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.

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