Design: Offline-First Feed Explained
ADVANCED › System Design
Every offline-first feed design starts with the same question: where does the UI actually read from? The instinctive answer is the network, with the database as a fallback for when the request fails. That's the wrong mental model, and correcting it is the first thing an interviewer wants to hear.
The right architecture makes the local database the single source of truth. In Android that's almost always Room. The UI layer holds no direct reference to the network at all, it observes a Flow exposed by a DAO. The repository's whole job is to keep that database current: successful network responses get written into Room, never handed straight to a screen. Because Room's Flow queries automatically re-run and re-emit whenever their underlying table changes, a write from anywhere, a background sync, a user action, a paging load, is enough to update every observer with zero manual refresh call.
This gives you two properties for free. First, the feed works with no connection, because reads never depend on one. Second, state stays consistent: there is exactly one place the UI looks, so there's no risk of the screen and the cache disagreeing.
@Dao
interface FeedDao {
@Query("SELECT * FROM feed_items ORDER BY timestamp DESC")
fun observeAll(): Flow<List<FeedItem>>
}
val feedItems: StateFlow<List<FeedItem>> = feedDao.observeAll()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
Zoom into exactly how the repository keeps that database fresh, a pattern usually called the network-bound resource. On every load it does four things in order: it immediately emits whatever is already cached, so the screen never sits blank while waiting on a request. It decides whether a network fetch is warranted. If the fetch succeeds, it writes the response into the database rather than returning it directly. And because that write lands in the same table the UI's Flow is observing, the database re-emits on its own, the repository doesn't need to push anything to the UI manually.
If the fetch fails, the cached value that was already emitted stays on screen, and the failure is surfaced through a separate error signal rather than replacing the data. This is exactly the mechanism behind something that looks like magic the first time you see it: a background sync job writes new rows into Room, and the feed on screen updates with no refresh call anywhere in the ViewModel. The Flow did the work.
fun getFeed(): Flow<List<FeedItem>> = flow {
emit(dao.getAll()) // 1. stale, instant
try {
val fresh = api.fetchFeed()
dao.upsertAll(fresh) // 2. write-through, never returned directly
emit(dao.getAll()) // 3. fresh, re-read after the write
} catch (e: IOException) {
// cached value already emitted; surface the error separately
}
}
Feeds are long lists, so the read side of this architecture needs pagination that's also backed by the database, not just the network. That's Paging 3's job split: a PagingSource reads pages straight out of Room, cheap, synchronous, works offline. RemoteMediator is the separate piece that keeps Room stocked with pages to read. Paging calls it when the on-disk pages run out, on APPEND when scrolling forward, on PREPEND when scrolling backward, or on a full REFRESH.
RemoteMediator never hands data to the UI itself. Its whole contract is: fetch a page from the network, write it into the database inside a transaction, and return a result telling Paging whether there's more to fetch. The PagingSource then picks up the newly written rows on its next read, same as it would for any other database write. This is the same source-of-truth discipline from the network-bound resource pattern, just applied to a paged list instead of a flat one.
class FeedRemoteMediator(
private val api: FeedApi,
private val db: FeedDatabase
) : RemoteMediator<Int, FeedItem>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, FeedItem>
): MediatorResult {
val page = getNextPage(loadType, state)
val response = api.getFeed(page) // fetch from network
db.withTransaction {
db.feedDao().insertAll(response.items) // persist to DB
db.remoteKeysDao().insertAll(keysFor(response, page))
}
return MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
}
}
There's one load type that needs special handling: REFRESH. It fires on the very first load and whenever the user pulls to refresh, and it means "start over from page one." If you handle it the same way as APPEND, just inserting the new page, you get duplicate rows sitting alongside stale ones, and the RemoteKeys table ends up pointing at pages that no longer make sense.
The correct handling is to treat REFRESH as a full atomic reset: inside a single transaction, clear the cached items and clear the remote keys, then insert the freshly fetched first page and its keys. Doing the clear and the insert in one transaction matters, if a crash or process death happened between two separate transactions, you could be left with keys but no items, or items but no keys, either of which breaks the next load. Every other load type, APPEND and PREPEND, skips the clearing step entirely and only inserts.
override suspend fun load(loadType: LoadType, state: PagingState<Int, FeedItem>): MediatorResult {
if (loadType == LoadType.REFRESH) {
db.withTransaction {
db.remoteKeysDao().clearAll()
db.feedDao().clearAll()
}
}
val page = if (loadType == LoadType.REFRESH) 0 else getNextPageFromKeys(state)
val response = api.getFeed(page)
db.withTransaction {
db.feedDao().insertAll(response.items)
db.remoteKeysDao().insertAll(keysFor(response, page))
}
return MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
}
RemoteMediator needs to know which page to fetch next, but a FeedItem itself has no idea what page it came from, that's not something you'd want to store on every row. The fix is a small side table, conventionally called RemoteKeys, that maps each item, or each page, to its previous and next page tokens.
When RemoteMediator handles an APPEND load, it looks at the last item currently loaded, looks up that item's key, and reads off the nextPage value to know what to request. A null nextPage means pagination is already exhausted in that direction. This table gets wiped and rebuilt on every REFRESH alongside the item cache, for the same reason: stale keys pointing at pages that no longer exist would send RemoteMediator chasing the wrong page. Naming this table unprompted in an interview is a good signal, it shows you've actually wired up Paging 3 against a network boundary rather than only used it against a fully in-memory list.
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val itemId: String,
val prevPage: Int?, // null = already at first page
val nextPage: Int? // null = end of pagination reached
)
suspend fun getNextPageFromKeys(state: PagingState<Int, FeedItem>): Int? {
val lastItem = state.lastItemOrNull() ?: return 0
return db.remoteKeysDao().remoteKeyForItem(lastItem.id)?.nextPage
}
RemoteMediator.load() has to tell Paging when the network side of the feed is genuinely exhausted, not just when the current page happened to come back with fewer items than expected. It does that through the return value, MediatorResult, which has exactly two shapes: Success, carrying a boolean endOfPaginationReached, or Error, carrying the exception that broke the load.
The convention is to check whatever signal the API gives you for "no more data," often a null or empty next-page token, and thread that straight into endOfPaginationReached. Get this wrong and one of two bad things happens: report false when you're actually out of pages, and Paging keeps calling load() forever, retrying against an API that has nothing left to give. Report true too early, and the user hits a hard wall a page or two before the feed is genuinely empty.
val response = api.getFeed(nextPage)
db.withTransaction { db.feedDao().insertAll(response.items) }
return MediatorResult.Success(
endOfPaginationReached = ___ // no token left means no more pages
)
Paging 3 exposes load state through CombinedLoadStates, with three positions, refresh, prepend, and append, each one of Loading, NotLoading, or Error. The right UI response is different depending on which position is in trouble, and mixing them up is a common mistake.
A refresh error means the very first load failed, there's nothing on screen yet, so a full-screen error view with a retry action is appropriate. An append error is different in kind: it means the list the user is already looking at loaded fine, and only the attempt to fetch the next page, scrolling forward, failed. Wiping the whole screen for that would throw away content the user can already see and interact with. The correct response is to leave the cached list exactly as it is and show a small retry control, typically a footer row at the bottom of the list, that calls adapter.retry() when tapped.
adapter.addLoadStateListener { states ->
val appendError = states.append as? LoadState.Error
retryFooter.isVisible = appendError != null
errorMessage.text = appendError?.error?.localizedMessage
}
retryButton.setOnClickListener { adapter.retry() }
There's a state that's easy to get wrong: a genuinely empty feed, zero items and nothing more to fetch, looks deceptively similar to a feed that's still on its very first load. Both can show zero items on screen at the moment you check. The difference is in the rest of CombinedLoadStates.
A first load in progress looks like refresh being Loading, with itemCount at zero simply because nothing has arrived yet, that calls for a loading spinner, not an empty view. A genuinely empty result looks like refresh having finished, NotLoading, pagination fully exhausted, endOfPaginationReached true on append, and itemCount still zero. Only that specific combination means there is truly nothing to show, and it's the one that should trigger an empty-state illustration rather than a spinner the user will wait on forever.
adapter.addLoadStateListener { states ->
val refresh = states.refresh
val isEmpty = refresh is LoadState.NotLoading &&
states.append.endOfPaginationReached &&
adapter.itemCount == 0
emptyView.isVisible = isEmpty
loadingSpinner.isVisible = refresh is LoadState.Loading
}
Background sync has two requirements a plain coroutine can't satisfy: it has to survive the app process being killed, by the system or by the user, and it should only run once there's actually a network to talk to. A coroutine launched in viewModelScope dies the moment that ViewModel is cleared, and it has no built-in way to wait for connectivity, you'd be hand-rolling both of those.
WorkManager is Android's answer: work you enqueue with it persists across process death and even device reboots, because it's backed by its own database under the hood, not just in-memory state. You attach constraints declaratively, NetworkType.CONNECTED being the obvious one for sync, and the system won't even attempt the work until that constraint holds. This is why sync logic belongs in a CoroutineWorker scheduled through WorkManager, not in a coroutine tied to a screen the user might leave at any moment.
// BAD: killed when the ViewModel is cleared or the process dies
viewModelScope.launch { syncFeed() }
// GOOD: durable, constraint-aware, survives process death
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
WorkManager.getInstance(context).enqueue(request)
Two WorkManager details are worth naming explicitly in an interview, because they're exactly the kind of thing that separates "I've read about WorkManager" from "I've shipped with it." First: a CoroutineWorker can return Result.retry() when a sync attempt fails with something transient, a timeout, a 5xx. That tells WorkManager to run the same unit of work again later, and by default the delay between attempts grows with exponential backoff rather than retrying instantly, so a flaky network doesn't turn into a tight failure loop. The backoff policy and initial delay are configurable if the default doesn't fit.
Second: periodic sync has to be scheduled exactly once, not once per app launch. Calling enqueue() with a fresh PeriodicWorkRequest on every cold start would stack up duplicate chains doing the same job. enqueueUniquePeriodicWork, given a stable name and ExistingPeriodicWorkPolicy.KEEP, fixes that: if a chain with that name is already scheduled, the new request is simply ignored, and only one chain ever runs.
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = try {
syncFeed()
Result.success()
} catch (e: IOException) {
Result.retry() // WorkManager reschedules with exponential backoff
}
}
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"feed_sync",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS).build()
)
Local-first writes create a real problem: the same item can be edited on two devices while one of them is offline, and when both eventually sync, which version wins? This is conflict resolution, and the simplest, most commonly cited strategy is last-write-wins. Every write carries a timestamp or version number, and when two versions of the same record meet, the one with the newer timestamp survives, the older one is discarded.
It's simple to implement and reason about, but it's not free of cost: last-write-wins can silently drop a concurrent edit that a user cared about, there's no merge, just a winner and a loser. Know the alternatives too, because interviewers often ask what you'd reach for beyond the simplest case. Server-authoritative resolution just always keeps the server's copy, simpler still, but it can discard valid local work outright. Field-level merge reconciles individual changed fields instead of whole records. CRDTs, conflict-free replicated data types, go further and guarantee a deterministic merge without a central authority at all, at the cost of real implementation complexity.
@Entity
data class FeedItem(
@PrimaryKey val id: String,
val content: String,
val updatedAt: Long // server-assigned epoch ms
)
fun merge(local: FeedItem, remote: FeedItem): FeedItem =
if (remote.updatedAt > local.updatedAt) remote else local
Reads aren't the only thing that need a strategy, writes do too, and which one you pick depends on how much the write can tolerate being wrong or delayed. There are three common shapes. Online-only writes go straight to the network first and only update the local database once the server confirms, failing outright if there's no connection, this is right for something like a payment, where you'd rather fail loudly than silently promise something you can't deliver. Queued writes get enqueued and drained later through WorkManager, tolerant of delay, a good fit for something like analytics events where losing a little timeliness costs nothing. Local-first writes update the database immediately, so the UI reflects the change instantly even with zero connectivity, then sync to the server afterward.
A 'like' button is the textbook case for local-first: the user expects the heart to fill in the instant they tap it, waiting on a round trip would feel broken. But local-first is exactly the write strategy that creates the conflict problem from the last chunk, since the same like can be toggled on two devices before either syncs, so picking local-first also means picking a conflict-resolution policy to go with it.
fun onLikeClicked(itemId: String) {
viewModelScope.launch {
db.feedDao().setLiked(itemId, liked = true) // 1. instant, offline-safe
WorkManager.getInstance(app).enqueue(
OneTimeWorkRequestBuilder<LikeSyncWorker>()
.setInputData(workDataOf("itemId" to itemId))
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
)
}
}
Everything above is worth building well, but naming how you'd test it is what separates a design that sounds right from one you've actually verified. For RemoteMediator, the strongest approach is calling load() directly against an in-memory Room database and a fake implementation of the API, then asserting on the result: the correct rows landed in the feed table, the correct RemoteKeys were written, and MediatorResult reports the right endOfPaginationReached for REFRESH, APPEND, and PREPEND. That's fast, deterministic, and runs with no device or real network involved.
For the source-of-truth side, assert that the Flow from your DAO actually re-emits after a write, a library like Turbine makes that straightforward to express as a test. For WorkManager, WorkManagerTestInitHelper and TestDriver let you verify constraints and scheduling, including that retry and unique-work behavior, without waiting on real backoff timers. And don't skip conflict resolution: feed two conflicting versions of the same record into your merge function and assert the newer one wins.
@Test
fun refresh_insertsItemsAndClearsOldData() = runTest {
val fakeApi = FakeFeedApi(items = listOf(FeedItem("1"), FeedItem("2")))
val db = Room.inMemoryDatabaseBuilder(context, FeedDatabase::class.java).build()
val mediator = FeedRemoteMediator(fakeApi, db)
val result = mediator.load(
LoadType.REFRESH,
PagingState(emptyList(), null, PagingConfig(pageSize = 10), 0)
)
assertTrue(result is MediatorResult.Success)
assertFalse((result as MediatorResult.Success).endOfPaginationReached)
assertEquals(2, db.feedDao().getAll().size)
}