Design: Offline-First Feed Explained

ADVANCED › System Design

Prompt: **design an offline-first news feed** that works with no connection, feels fast, and reconciles with the server when it can. Applying the framework from the previous lesson, the first design decision an interviewer wants to hear is: where does the UI read from?

The answer is never "the network," it's the local database. **Room is the single source of truth.** The UI observes a Flow from a DAO; the repository's only job is to keep that DB fresh, network responses land in Room, they never go straight to the screen.

@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())

Because Flow queries re-run automatically when the underlying table changes, a background sync that writes into Room is enough to update the screen, no manual refresh call needed.

Zoom into the repository's read path, the **network-bound resource** pattern. On every load it: emits the cached value immediately, decides whether to hit the network, writes a successful response into the DB (which re-emits to the UI automatically), and on failure just leaves the cached value on screen alongside a separate error signal.

fun getFeed(): Flow<List<FeedItem>> = flow {
    emit(dao.getAll())              // 1. stale, instant
    try {
        val fresh = api.fetchFeed()
        dao.upsertAll(fresh)         // 2. write-through
        emit(dao.getAll())           // 3. fresh, re-read
    } catch (e: IOException) {
        // cached value already emitted; surface the error separately
    }
}

Notice the network call never returns data straight to the UI, it only ever writes to the DB. That single rule is what keeps Room the SSOT instead of a second, competing source of truth.

For paging, PagingSource reads from Room, and RemoteMediator is the bridge that keeps Room fed from the network. It's invoked when the DB runs dry (APPEND or PREPEND) or when a full reload is requested (REFRESH). On REFRESH you must atomically wipe the stale cache and keys before inserting page one, otherwise you'd mix old and new pages or leave orphaned keys behind:

override suspend fun load(loadType: LoadType, state: PagingState<Int, FeedItem>): MediatorResult {
    if (loadType == LoadType.REFRESH) {
        db.withTransaction {
            db.remoteKeysDao().clearAll()
            db.feedDao().clearAll()
        }
    }
    val response = api.getFeed(page)
    db.withTransaction { db.feedDao().insertAll(response.items) }
    return MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
}

Where does RemoteMediator learn which page to fetch next? Not from the feed items themselves, an item doesn't know its own page token. That's what the **RemoteKeys table** is for: a small side table mapping each item (or page) to its previous or next page token.

@Entity(tableName = "remote_keys")
data class RemoteKey(
    @PrimaryKey val itemId: String,
    val prevPage: Int?,   // null = already at the first page
    val nextPage: Int?    // null = end of pagination reached
)

RemoteMediator looks up the key for the item at the loaded edge to decide what to request next. It's a small piece of plumbing, but naming it unprompted is a strong signal in an interview, it shows you've actually implemented Paging 3 with a network boundary, not just read about it.

Syncing needs to survive the app being killed and should only run when there's connectivity, that rules out a plain coroutine in the ViewModel, which dies with it. **WorkManager** is the durable, constraint-aware home for this:

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
    }
}

val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
    .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
    .build()

WorkManager.getInstance(context)
    .enqueueUniquePeriodicWork("feed_sync", ExistingPeriodicWorkPolicy.KEEP, request)

Two details worth naming out loud: Result.retry() gets automatic exponential backoff, and enqueueUniquePeriodicWork with KEEP stops duplicate sync chains from piling up every time the app cold-starts.

What happens when the same item was edited both offline and on the server? That's **conflict resolution**, and the simplest, most commonly cited approach is **last-write-wins**: attach a timestamp or version to every write, and whichever is newer survives.

fun merge(local: FeedItem, remote: FeedItem): FeedItem =
    if (remote.updatedAt > local.updatedAt) remote else local

It's simple but can silently drop a concurrent edit, know the alternatives too: server-authoritative (server always wins, simplest but can discard valid local work), field-level merge, or CRDTs for state that genuinely needs to merge. This connects to the three **write strategies**: online-only (write network first, fail if offline, for example a payment), queued (enqueue via WorkManager, drain later, fine for analytics), and local-first (write Room immediately, sync after, best for a like button, but exactly the case that needs conflict resolution).

Last piece: distinguishing **loading versus empty versus error** on screen, and how you'd test the whole design. Paging 3 exposes CombinedLoadStates with refresh or append or prepend, each Loading, NotLoading, or Error. A genuine empty result is refresh NotLoading and pagination exhausted and zero items, that's different from refresh Loading with zero items, which is just still fetching:

adapter.addLoadStateListener { states ->
    val isEmpty = states.refresh is LoadState.NotLoading &&
                  states.append.endOfPaginationReached &&
                  adapter.itemCount == 0
    emptyView.isVisible = isEmpty
    loadingSpinner.isVisible = states.refresh is LoadState.Loading
}

For testing, call RemoteMediator.load() directly against an in-memory Room DB and a fake API, asserting the right rows and RemoteKeys get written and the right MediatorResult comes back for REFRESH or APPEND. Assert the Flow re-emits after a sync with Turbine, and test WorkManager scheduling with WorkManagerTestInitHelper.

Back to Design: Offline-First Feed