Android & Mobile

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 smartphone displaying music on a desk with computer monitors showing code
Text-free hero visual supporting Paging 3 with Room and Kotlin Flow.

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:

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

View all articles