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