RemoteMediator and Caching
Combine network and database paging.
RemoteMediator and Caching is a free Android Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Android Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Offline-First Paging
A plain network PagingSource works, but if the connection drops the list is empty. For a robust app you want the database as the single source of truth and the network filling it in the background.
That is exactly what RemoteMediator enables: page from the local database, and fetch more from the network when the database runs out.
The Architecture
With RemoteMediator the data flows in one direction:
- The UI pages from a Room
PagingSource - When Room nears its end,
RemoteMediatorfetches the next network page - The network result is written into Room
- Room emits the new rows, and the UI updates
The user always sees cached data instantly, even offline.
// Network ---> RemoteMediator ---> Room (source of truth) ---> UI
// UI never reads the network directlyRoom PagingSource for Free
Room can generate a PagingSource automatically. Just declare a query that returns PagingSource<Int, Entity> and Room implements it.
This is the source the UI actually pages from.
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY position ASC")
fun pagingSource(): PagingSource<Int, ArticleEntity>
}Subclassing RemoteMediator
RemoteMediator<Key, Value> has one required method, load(), plus an optional initialize().
load() receives a LoadType (REFRESH, PREPEND or APPEND) and the current PagingState, then returns a MediatorResult.
import androidx.paging.ExperimentalPagingApi
import androidx.paging.RemoteMediator
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
// override suspend fun load(loadType, state): MediatorResult
}Handling LoadType
In load() you decide which page to fetch based on the LoadType:
- REFRESH - load the first page (or around the anchor)
- PREPEND - usually nothing to do; return
endOfPaginationReached = true - APPEND - load the page after the last one we have
val page: Int = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND ->
return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastKey = db.remoteKeyDao().last()?.nextKey
?: return MediatorResult.Success(endOfPaginationReached = true)
lastKey
}
}Remote Keys Table
Unlike a PagingSource, a RemoteMediator cannot return next/prev keys directly - they must be persisted. Store them in a small remote keys table alongside your data.
This lets you resume APPEND/PREPEND across app restarts.
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val articleId: Int,
val prevKey: Int?,
val nextKey: Int?
)Writing Network Results to Room
After fetching a page, write the items and their keys to Room inside a transaction. On REFRESH, clear the old tables first so the cache stays consistent.
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.remoteKeyDao().clearAll()
db.articleDao().clearAll()
}
val nextKey = if (response.items.isEmpty()) null else page + 1
db.remoteKeyDao().insertAll(
response.items.map { RemoteKey(it.id, prevKey = page - 1, nextKey = nextKey) }
)
db.articleDao().insertAll(response.items.map { it.toEntity() })
}Returning MediatorResult
Finish load() by returning a MediatorResult:
Success(endOfPaginationReached = true)when there are no more pagesSuccess(endOfPaginationReached = false)when more may existError(e)on failure - surfaced as aLoadState.Error
return try {
val response = api.getArticles(page = page, size = state.config.pageSize)
db.withTransaction { /* write items + keys */ }
MediatorResult.Success(
endOfPaginationReached = response.items.isEmpty()
)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}Wiring Pager with remoteMediator
Build the Pager with both the remoteMediator and the Room pagingSourceFactory. The mediator fills Room; the UI pages from Room.
Because it uses an experimental API, opt in with @OptIn(ExperimentalPagingApi::class).
@OptIn(ExperimentalPagingApi::class)
fun articles(): Flow<PagingData<ArticleEntity>> = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ArticleRemoteMediator(api, db),
pagingSourceFactory = { db.articleDao().pagingSource() }
).flowLooking Up Keys for APPEND
For an APPEND load you need the nextKey of the last loaded item. Use the PagingState anchor to find the right remote key from your keys table.
Helper functions keep load() readable across REFRESH, APPEND and PREPEND.
private suspend fun lastRemoteKey(
state: PagingState<Int, ArticleEntity>
): RemoteKey? {
return state.pages.lastOrNull { it.data.isNotEmpty() }
?.data?.lastOrNull()
?.let { db.remoteKeyDao().keyFor(it.id) }
}
// In load(): for APPEND
val nextKey = lastRemoteKey(state)?.nextKey
?: return MediatorResult.Success(endOfPaginationReached = true)Caching and Freshness
Because data lives in Room, the app loads instantly and works offline. To avoid stale content, override initialize() to decide whether to refresh on launch based on how old the cache is.
override suspend fun initialize(): InitializeAction {
val lastUpdate = db.remoteKeyDao().lastUpdatedMillis() ?: 0L
val cacheTimeout = TimeUnit.HOURS.toMillis(1)
return if (System.currentTimeMillis() - lastUpdate >= cacheTimeout) {
InitializeAction.LAUNCH_INITIAL_REFRESH
} else {
InitializeAction.SKIP_INITIAL_REFRESH
}
}Quick Check
In a RemoteMediator setup, what is the single source of truth that the UI pages from?
Recap: RemoteMediator and Caching
You built an offline-first paging pipeline:
- The UI pages from a Room
PagingSource- the single source of truth RemoteMediator.load()handlesREFRESH/PREPEND/APPENDand writes results to Room in a transaction- A remote keys table persists next/prev keys across restarts
- The
Pagertakes both aremoteMediatorand apagingSourceFactory initialize()controls cache freshness
You can now load endless lists efficiently, online or offline. Congratulations on completing the Paging 3 course.
Frequently asked questions
Is the “RemoteMediator and Caching” lesson free?
Yes — the full text of “RemoteMediator and Caching” is free to read here on the web, and the Android Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Android Academy course, upgrade to CoddyKit PRO.
What will I learn in “RemoteMediator and Caching”?
Combine network and database paging. You practise Android Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Android Academy?
No prior experience is required. Android Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “RemoteMediator and Caching” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Android Academy lesson?
Yes. Every Android Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Why Paging
- PagingSource and Pager
- Paging in Compose Lists
- RemoteMediator and Caching