Paging 3 with Room and Kotlin Flow
A practical guide to Android pagination with Room, Kotlin Flow, Paging 3, and RemoteMediator, focused on architecture decisions, failure modes, and offline behavior.
Table of Contents6 sections

A hands-on view of the code and data boundaries behind a Architecting Paging 2 With Room And Kotlin Flow For Offline migration.
Android Calendar Date Calculations pagination gets difficult when the list is no longer just “load page 2.” The real problem is deciding which layer owns the truth when the network fails, Room changes underneath the list, the user retries, or Android recreates the screen.
For most offline-capable apps, a useful default is simple: the UI reads from Room; the network updates Room; Paging coordinates how much data is exposed at once. From there, choose manual pagination or Paging 3 based on the coordination you actually need.
This article focuses on that decision rather than treating PagingSource or RemoteMediator as mandatory architecture.
Start with the pagination contract
Before choosing a library, define what “correct” means for the screen.
A paginated list should answer a few questions clearly:
- What is the source of truth: network response, memory, or Room?
- What happens to already-loaded items when the next request fails?
- Can records be inserted or reordered while the user is scrolling?
- Does refresh replace the local dataset or merge into it?
- How is the end of pagination determined?
- What state must survive navigation or process recreation?
Those answers matter more than whether the implementation begins with LIMIT/OFFSET, a cursor, or Paging 3.
Manual pagination is still a valid option
For a small, predictable list, manual pagination can be easier to reason about. A DAO can expose bounded queries and the ViewModel can explicitly own loading, retry, and end-of-list state.
@Dao
interface ItemDao {
@Query("SELECT * FROM items ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
suspend fun page(limit: Int, offset: Int): List<ItemEntity>
}
This approach is attractive when the screen has one data source, modest datasets, and simple append behavior. The trade-off is that your code now owns coordination that Paging 3 normally handles: overlapping requests, refresh behavior, append errors, deduplication, and restoring a coherent list after invalidation.
Offset pagination is also sensitive to a changing dataset. If rows are inserted or deleted before the current offset, the next page can overlap or skip records. A stable cursor or keyset strategy can be a better fit when the backend and sort order support it, but it should be chosen because of the data contract, not because of a blanket complexity claim.
Let Room become the source of truth
For an offline-capable screen, the cleanest boundary is often to make Room the data source observed by the UI. Network responses update the database; the UI reacts to database changes.
That keeps network availability out of the rendering contract. If a refresh fails, cached rows can remain visible instead of disappearing because an API call failed.
Room integrates directly with Paging by allowing DAO queries to return a PagingSource:
@Dao
interface ItemDao {
@Query("SELECT * FROM items ORDER BY timestamp DESC")
fun pagingSource(): PagingSource<Int, ItemEntity>
}
Then the repository exposes a Flow<PagingData<ItemEntity>>:
class ItemRepository(
private val dao: ItemDao
) {
val items = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false
),
pagingSourceFactory = dao::pagingSource
).flow
}
Room tracks invalidation for the tables involved in the query, so database changes can produce a fresh PagingSource. That is useful, but it also means database writes are part of the paging contract: stable ordering and stable item identity still matter.
If you are migrating from the older Paging stack, the RayLabs guide on moving Paging 2 + Room toward Paging 3 and Flow covers the migration boundary in more detail.
Add RemoteMediator only when the network and database must coordinate
RemoteMediator earns its complexity when the app needs to fetch remote pages while presenting Room as the local source of truth.
The important mental model is not “RemoteMediator loads the list.” It does not. The UI still receives pages from the local PagingSource. The mediator decides when remote data should be fetched and written into the database.
A simplified shape looks like this:
@OptIn(ExperimentalPagingApi::class)
class ItemRemoteMediator(
private val database: AppDatabase,
private val api: ApiService
) : RemoteMediator<Int, ItemEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ItemEntity>
): MediatorResult = try {
val cursor = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> state.lastItemOrNull()?.timestamp
}
val response = api.fetchAfter(
timestamp = cursor,
limit = state.config.pageSize
)
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.itemDao().clearAll()
}
database.itemDao().insertAll(response.items)
}
MediatorResult.Success(
endOfPaginationReached = response.items.isEmpty()
)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
Real APIs often need remote keys, query-specific cache state, or a server cursor rather than deriving the next request from the final row. Treat that key strategy as part of the persistence design. A mediator that guesses the next page from unstable local ordering can look correct in a demo and fail under refreshes or inserts.
The production bugs live in transitions
The happy path is rarely the part that breaks. Test the transitions where two layers disagree.
Append fails after several pages
Keep cached rows visible. Show the append failure near the list boundary and let the user retry without resetting the entire screen.
Refresh fails while cache exists
A refresh error should not automatically mean an empty screen. If Room still has usable data, preserve it and communicate that freshness could not be updated.
The database changes during scrolling
Use stable ordering and stable UI keys. Test inserts, deletes, and updates while the viewport is far from the first page. Invalidation is expected; duplicate identity and unstable sorting are not.
Process death or navigation recreates the screen
Do not assume a ViewModel alone solves restoration. Persist the actual screen inputs that matter, query, filters, selected sort, and verify what scroll behavior your UI can reliably restore when Paging data is re-created.
Empty response versus end of pagination
Define this from the API contract. An empty page may mean “finished,” but some APIs provide an explicit cursor or hasNext field. Prefer the server’s pagination contract over inference when it exists.
A practical decision rule
Use manual pagination when the list is small enough that owning request coordination is genuinely simpler.
Use Room PagingSource when the local database is the source of truth and the list benefits from Paging’s loading and invalidation model.
Add RemoteMediator when remote pagination must refill that local source of truth without making the UI observe network responses directly.
The architecture becomes easier to maintain when each layer has one job: the API provides remote data, Room owns durable local state, Paging exposes windows of that state, and the UI renders load states without becoming the synchronization engine.
Before calling the implementation done, test refresh, append failure, retry, dataset mutation, empty data, and process recreation. If those transitions are predictable, the pagination architecture is doing more than just scrolling successfully in a demo.
Continue Exploring
You Might Also Like

Mastering List to String Conversion in Mobile Development
An in-depth guide on handling list to string conversion, managing Android lifecycles, and avoiding memory leaks during state transformation.

Android Date and Time: Model Instants, Local Dates, and Time Zones Correctly
Learn how to model and format date and time in Android with Kotlin by separating absolute instants, local calendar values, time zones, localization, and testable presentation logic.

FCM Delivery Monitoring: Know What Sent, Delivered, and Opened Actually Mean
A practical guide to Firebase Cloud Messaging observability that separates send acceptance, aggregated delivery, app processing, and user interaction instead of treating one success response as proof of delivery.