Design: Offline-First Feed Quiz
ADVANCED › System Design
In an offline-first feed, where should the UI layer read its data from?
- Directly from the network, falling back to the DB on failure
- From the local database, which is the single source of truth
- From a volatile in-memory cache that is never persisted anywhere
- From whichever of the network or local database responds first
Answer: From the local database, which is the single source of truth
The local DB is the canonical source of truth; the UI observes it via Flow and writes update it first, so reads stay consistent and work offline.
What is the primary role of RemoteMediator in Paging 3?
- It draws the list items directly on the screen
- It replaces PagingSource and sends pages straight to UI
- It loads network pages and stores them in the local DB
- It only does the initial refresh, then stops being used
Answer: It loads network pages and stores them in the local DB
RemoteMediator bridges network and DB: when the DB-backed PagingSource is exhausted it loads more from the network and persists it, keeping the DB authoritative.
When RemoteMediator.load() is called with LoadType.REFRESH, what is the typical correct behavior?
- Clear cached items and keys, then insert the first page atomically
- Append the new page to the end without clearing existing data first
- Throw an exception, since REFRESH is not a supported load type
- Update only the RemoteKeys table and leave the item cache alone
Answer: Clear cached items and keys, then insert the first page atomically
REFRESH means reload from the top, so you atomically clear cached items and keys and insert the new first page to avoid duplicates and stale keys.
Last-write-wins conflict resolution relies on what mechanism?
- Alphabetical ordering of record IDs when conflicts are compared
- Whichever device has the fastest network connection at the moment
- Always keeping the value already stored on the server unchanged
- Timestamp or version metadata so older writes get discarded
Answer: Timestamp or version metadata so older writes get discarded
Devices attach a timestamp/version; the authoritative source keeps newer writes and discards anything older than its current state.
Why prefer WorkManager over a plain ViewModel coroutine for background sync?
- It makes the UI render faster by automatically skipping layout work
- It survives process death and reboots, and waits for connectivity
- It is the only way to call suspend functions from Android code
- It removes the need to store sync state in a local database
Answer: It survives process death and reboots, and waits for connectivity
Sync must survive the app being killed and should wait for network; WorkManager provides durable, constraint-aware, deduplicated execution that a ViewModel coroutine cannot.
A CoroutineWorker doing sync returns Result.retry(). What happens?
- The app crashes immediately, killed by an unhandled worker exception thrown
- It retries instantly on the main thread in a tight busy loop
- WorkManager reschedules it later with exponential backoff by default
- The unique work chain is cancelled permanently and never runs again
Answer: WorkManager reschedules it later with exponential backoff by default
Result.retry() tells WorkManager to run the work again later, increasing the delay with exponential backoff (configurable) so transient failures recover gracefully.
The feed shows cached items, but loading the next page from the network fails. What is the best Paging 3 UX?
- Keep the cached list and show a retry footer on append LoadState.Error
- Clear the entire cached list and swap in a full-screen error page instead
- Let the app crash so the user is forced to restart it from scratch
- Block the entire UI behind a modal dialog until the network recovers
Answer: Keep the cached list and show a retry footer on append LoadState.Error
Cached content should remain visible; an append error is local to the bottom of the list, so a footer with a retry action is the correct, non-destructive response.
What does the RemoteKeys table store and why is it needed?
- The rendered scroll position and recycled view state of every list row
- Cached network response bodies stored as raw JSON blobs keyed by full request URL
- Maps items or pages to prev/next tokens so RemoteMediator knows what to fetch
- The user's OAuth authentication tokens for calling the feed API
Answer: Maps items or pages to prev/next tokens so RemoteMediator knows what to fetch
RemoteMediator reads the RemoteKeys table to find the next or previous page token for the item at the edge of the loaded data, since the page tokens are not part of the displayed entities themselves.
After a background sync writes new rows into Room, why does the feed UI update without any manual refresh call?
- Room returns Flow queries that re-emit when tables change
- The ViewModel polls the database every second on a timer, then updates UI
- WorkManager directly pushes the fresh rows into the RecyclerView adapter
- The Activity is recreated whenever the database gets written, refreshing UI
Answer: Room returns Flow queries that re-emit when tables change
A DAO query returning Flow is reactive: Room re-runs it and emits a new list whenever the queried tables are modified, so consumers collecting the Flow get the fresh data automatically.
For a 'like' button that must feel instant even with no connection, which write strategy is most appropriate?
- Online-only: wait for server confirmation before changing state
- Local-first: write to the DB now, then sync later with conflicts
- Read-only: don’t store the like; fetch it fresh from server each time
- Synchronous network: do the request on the main thread first
Answer: Local-first: write to the DB now, then sync later with conflicts
Local-first writes update Room right away so the UI reflects the change instantly offline, deferring the network sync; because two devices can diverge, it requires a conflict-resolution policy.
How does RemoteMediator.load() signal that there are no more pages to fetch from the network?
- It returns MediatorResult.Success(endOfPaginationReached = true)
- It throws an EndOfPaginationException that Paging catches internally
- It returns MediatorResult.Error(null) to signal the end of the feed
- It returns null from load() instead of a MediatorResult
Answer: It returns MediatorResult.Success(endOfPaginationReached = true)
Returning MediatorResult.Success(endOfPaginationReached = true) tells Paging the network has been fully consumed, so it stops invoking the mediator for further APPEND/PREPEND loads.
Which approach correctly prevents duplicate periodic sync jobs from stacking up each time the app launches?
- Call enqueue() with a new PeriodicWorkRequest in every onResume
- Use a static boolean in Application to skip scheduling after launch
- Schedule a fresh OneTimeWorkRequest on every cold start instead
- Use enqueueUniquePeriodicWork with a stable name and KEEP
Answer: Use enqueueUniquePeriodicWork with a stable name and KEEP
enqueueUniquePeriodicWork with a unique name and KEEP ensures only one periodic chain exists for that name, so repeated scheduling calls do not create duplicate workers.
Using Paging 3 LoadState, how do you detect a genuine empty state (no items at all) versus still loading?
- loadState.refresh is still Loading while the visible itemCount remains at 0
- loadState.append is Error while the current itemCount is still 0
- refresh is NotLoading, endOfPaginationReached true, and itemCount is 0
- loadState.prepend is Loading while the visible itemCount is 0
Answer: refresh is NotLoading, endOfPaginationReached true, and itemCount is 0
An empty result means the refresh finished (NotLoading), pagination is exhausted (endOfPaginationReached), and there is genuinely nothing to show (itemCount 0); refresh Loading with 0 items is just the initial fetch in progress.
What is the most effective way to unit-test a RemoteMediator's load() logic?
- Install the release build on a real device and scroll the whole feed by hand
- Call load() on an in-memory Room DB and fake API, asserting rows and keys
- Mock the PagingSource and never construct a real in-memory Room database
- Only verify the behavior in production against the live network API
Answer: Call load() on an in-memory Room DB and fake API, asserting rows and keys
Calling load() against an in-memory Room DB with a fake network lets you deterministically assert that the correct entities and remote keys are persisted and that the right MediatorResult is returned for REFRESH/APPEND/PREPEND.