Android & Mobile

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
A smartphone beside a laptop and tools in a hands-on development workspace.
Text-free hero visual supporting Android Paging 3 with Kotlin Flow: Room, RemoteMediator, and Offline-First Data.

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:

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:

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:

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

View all articles