Android Paging 3 with Kotlin Flow: Room, RemoteMediator, and Offline-First Data
A practical architecture guide to Android Paging 3 with Kotlin Flow, Room, and RemoteMediator, focused on source-of-truth decisions, refresh semantics, failure states, and offline behavior.
Table of Contents12 sections

Architecting Paging 2 Room Kotlin Flow reliability comes from explicit ownership between the API, Room, Paging, and the UI.
Paging 3 becomes difficult when the requirement is no longer simply “load the next page.” The hard part is deciding which layer owns the truth when the network fails, Room changes underneath the list, a refresh races with an append, or the process is recreated.
For an offline-capable Android screen, a useful default is:
API → RemoteMediator → Room → PagingSource → Pager → Flow<PagingData<T>> → UI
The important detail is the direction of truth. The UI does not render the latest network response directly. It renders data paged from Room. Network work updates Room, and Room invalidation lets Paging expose the new local state.
That architecture is not mandatory for every list. If the data is already local, Room can provide a PagingSource without a mediator. If you only page a remote API and do not need durable offline data, a network PagingSource may be enough. The goal is not to maximize the number of Jetpack classes. It is to make ownership predictable.
If you want the shorter migration-oriented version of this decision, RayLabs also has a guide to Paging 3 with Room and Kotlin Flow. This article goes deeper into the end-to-end Flow contract and the failure states that tend to appear after the happy path works.
Choose the Paging Shape Before Writing the Pager
Three architectures are commonly mixed together in tutorials even though they solve different problems.
| Requirement | Local source | Remote coordinator | Best starting point |
|---|---|---|---|
| Page a remote API only | none | none | custom network PagingSource |
| Page data already stored locally | Room | none | Room PagingSource |
| Page remote data and keep an offline cache | Room | RemoteMediator |
Room PagingSource + mediator |
This decision matters because RemoteMediator is not a generic requirement for Paging 3. It earns its complexity only when remote pagination must populate or refresh a local source of truth.
Let Room Own the Durable List
For an offline-first screen, the DAO can expose a PagingSource directly:
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY createdAt DESC, id DESC")
fun pagingSource(): PagingSource<Int, TransactionEntity>
}
Then the repository builds the Pager:
class TransactionRepository(
private val database: AppDatabase,
private val api: TransactionApi
) {
@OptIn(ExperimentalPagingApi::class)
fun transactions(): Flow<PagingData<TransactionEntity>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false
),
remoteMediator = TransactionRemoteMediator(database, api),
pagingSourceFactory = database.transactionDao()::pagingSource
).flow
}
The UI-facing stream still comes from the local query. The mediator does not become a second source of UI truth.
That boundary has an important consequence: database ordering is part of the paging contract. If multiple records can share the same timestamp, add a stable secondary key. Otherwise a refresh or invalidation can reorder equal rows and produce apparent duplicates, jumps, or missing items even when the network payload is correct.
Cache the Paging Stream at the ViewModel Boundary
The ViewModel should expose the paging stream without becoming the synchronization engine:
class TransactionViewModel(
repository: TransactionRepository
) : ViewModel() {
val transactions = repository
.transactions()
.cachedIn(viewModelScope)
}
cachedIn(viewModelScope) is useful because multiple downstream collections can share already loaded paging data for the lifetime of that ViewModel. It does not turn the ViewModel into durable storage. Process death can still recreate the ViewModel, so persist the screen inputs that are actually needed to reconstruct the query, such as filter, search term, account ID, or sort mode.
A useful rule is: cache the paging stream; persist the query contract.
RemoteMediator Coordinates Writes, Not Rendering
A RemoteMediator answers a narrower question: when Paging asks for REFRESH, APPEND, or PREPEND, should remote data be fetched and how should it be committed locally?
A simplified mediator might look like this:
@OptIn(ExperimentalPagingApi::class)
class TransactionRemoteMediator(
private val database: AppDatabase,
private val api: TransactionApi
) : RemoteMediator<Int, TransactionEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, TransactionEntity>
): MediatorResult {
return try {
val cursor = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true
)
LoadType.APPEND -> database.remoteKeyDao().nextCursor()
}
val response = api.getTransactions(
cursor = cursor,
limit = state.config.pageSize
)
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.transactionDao().clearAll()
database.remoteKeyDao().clear()
}
database.transactionDao().upsertAll(response.items)
database.remoteKeyDao().save(response.nextCursor)
}
MediatorResult.Success(
endOfPaginationReached = response.nextCursor == null
)
} catch (error: IOException) {
MediatorResult.Error(error)
} catch (error: HttpException) {
MediatorResult.Error(error)
}
}
}
The exact key strategy depends on the backend. Page numbers, item IDs, timestamps, and opaque cursors are not interchangeable. Prefer the server’s explicit pagination contract when one exists.
Treat Remote Keys as Data, Not Plumbing
A common demo derives the next page from the last visible item. That can work for stable datasets, but it becomes fragile when records can be inserted, deleted, or reordered remotely.
If the backend returns an opaque nextCursor, store that cursor deliberately. If pagination keys belong to individual records, persist remote keys with those records. Whichever design you choose, update records and keys in the same database transaction so a crash cannot leave the cache claiming page N was stored while its continuation key says something else.
This is one of the biggest differences between a demo that scrolls and a paging system that can recover after failure.
Define REFRESH Before You Implement APPEND
Most pagination bugs are really refresh-semantics bugs.
Ask these questions explicitly:
- Does refresh replace all cached rows or merge server changes into them?
- Are locally edited fields allowed to survive a refresh?
- What happens when refresh fails but cached rows exist?
- Can an append started before refresh commit afterward?
- Does an empty response mean the dataset is empty or only that this cursor has no rows?
For a server-owned feed, clearing remote rows and keys inside the same transaction as the first refreshed page can be appropriate. For a mixed local/remote dataset, destructive replacement may erase legitimate local state. The correct answer comes from data ownership, not from Paging itself.
Keep Load States Separate
Paging exposes source and mediator load states because “loading” is not one state.
A production screen should distinguish at least these cases:
| State | Existing rows | Useful UI behavior |
|---|---|---|
| Initial load | none | full-screen progress |
| Initial failure | none | full-screen error + retry |
| Refresh | yes | keep rows visible; show refresh feedback |
| Refresh failure | yes | keep cached rows; surface non-destructive error |
| Append | yes | footer progress |
| Append failure | yes | footer retry; do not replace the list |
| End reached | yes | stop requesting more pages |
This distinction prevents a temporary append failure from blanking a perfectly usable cached list.
Filters and Search Must Rebuild the Paging Contract
A filter is not merely a UI transformation when it changes the database query.
If the screen pages transactions by account or category, model that input upstream:
val transactions = filter
.flatMapLatest { selectedCategory ->
repository.transactions(selectedCategory)
}
.cachedIn(viewModelScope)
flatMapLatest cancels collection of the old paging stream when the query changes. That makes cancellation part of the architecture instead of letting old requests continue updating a screen the user is no longer viewing.
Be equally deliberate on the database side. Filtering thousands of rows after Paging loads them defeats the point of paging. Put stable filters and ordering into the query whenever possible.
The Bugs Live in Transitions
The happy path is straightforward: launch, fetch, insert, scroll. Production reliability is determined by transitions between states.
Test at least this matrix:
| Scenario | What must remain true |
|---|---|
| App starts offline with cache | cached rows render without waiting for network |
| App starts offline without cache | initial error is recoverable |
| Append request fails | existing rows stay visible and retry is possible |
| Refresh fails with cache | stale-but-usable rows remain on screen |
| Same page is requested twice | primary keys/upsert prevent duplicate durable rows |
| Server inserts records at the top | ordering and key strategy do not skip or duplicate rows |
| Filter changes during load | old stream is cancelled or safely ignored |
| Process is recreated | query inputs can rebuild the paging stream |
| Database is invalidated | a new PagingSource produces stable ordering |
| API reports end of data | mediator stops unnecessary append requests |
Do not verify only that page 2 appears. Verify what happens when page 2 fails, retries, overlaps a refresh, or arrives after the user changes the query.
Know What Kotlin Flow Is Doing Here
Building A Reliable Md5 Kotlin Helper Flow is valuable in Paging architecture, but not because every layer should expose a Flow for its own sake.
Its useful responsibilities here are concrete:
Pager.flowexposesPagingDataasynchronously;flatMapLatestcan switch paging streams when query inputs change;cachedInscopes paging data sharing to a lifecycle owner such as a ViewModel;- Room invalidation causes the underlying paging source to be recreated when observed tables change.
The API request itself can remain a suspending function. The database write can remain a suspending transaction. Reactive streams are most useful at boundaries where values genuinely change over time.
A Practical Architecture Checklist
Before calling an Android Paging 3 implementation complete, you should be able to answer:
- Is the UI paging the network directly or paging Room?
- If Room is the source of truth, does every remote success reach the UI through Room?
- Does the backend use page numbers, keys, or opaque cursors?
- Where are continuation keys stored?
- Are data and remote-key writes atomic?
- Is list ordering deterministic?
- What exactly does refresh replace?
- What does an empty remote page mean?
- Can cached rows remain visible during network failure?
- Do filter changes cancel obsolete paging work?
- Which screen inputs survive process recreation?
- Have refresh, append failure, retry, invalidation, and end-of-pagination been tested?
If those answers are explicit, the implementation is much easier to reason about than a collection of Pager, Flow, and RemoteMediator snippets copied independently.
The RayLabs Rule: One Layer, One Ownership Job
A reliable offline-first paging pipeline can be summarized as four ownership rules:
The API owns remote truth. Room owns durable local truth. Paging owns windows and load coordination. The UI owns presentation of data and load states.
Kotlin Flow connects those boundaries without requiring the UI to orchestrate synchronization itself.
That separation is what makes the architecture resilient. When the network disappears, the UI can still render Room. When Room changes, Paging can invalidate and reload. When the query changes, Flow can switch streams. When a remote request fails, the failure can be retried without pretending the cached list vanished.
Before optimizing page size or prefetch distance, make those ownership rules explicit. Most difficult Paging bugs are not caused by a bad number in PagingConfig; they come from two layers believing they both own the same state.
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.