Articles
Android Paging Interview Questions: Load States, RemoteMediator, and Refresh Consistency
Practice Android Paging interview questions with a worked stale-cursor timeline, Room transaction policy, load-state decisions, and refresh race tests.
A feed can look correct on first launch and still fail the Android Paging interview. The user scrolls offline, the app reconnects, a refresh inserts newer records, and the next append reuses an old cursor. Now a card appears twice—or disappears. A strong answer traces the database rows, remote key, and UI load state as one system, not three unrelated APIs.
This is an original Paging 3 interview exercise, not a report of a particular company's interview. It assumes a Room-backed feed with a network cursor and a RemoteMediator. Here we concentrate on the moment a cached feed refreshes while pagination is in flight.
The interview prompt: a feed that duplicates after refresh
Your app displays a newest-first article feed. Room is the UI's source of truth. The backend returns stable article IDs, a page of items, and an opaque nextCursor. The user has loaded two pages, goes offline, returns later, pulls to refresh, and immediately scrolls down. The server has inserted new articles at the front. The UI briefly shows old cached items, then repeats one article after the next page arrives.
Explain the data flow, identify how stale keys or non-atomic writes could cause the duplicate, implement a defensible mediator policy, and specify what the user should see during initial load, append failure, offline viewing, and refresh. Do not assume that a cursor is an article ID or that an empty page always means the feed is permanently exhausted. Ask whether cursors are stable across feed versions, whether the backend de-duplicates across page boundaries, and whether refresh should replace or merge the local feed.
The answer should preserve two invariants: every visible feed item has one stable ID and ordering rule, and the key used for the next network request belongs to the same feed generation as the cached items. An interview answer that merely adds distinctBy(id) at render time hides the underlying synchronization defect.
Draw the real Paging data path
In this architecture, the adapter reads PagingData backed by Room's PagingSource. When the local data needs more network content, RemoteMediator.load() obtains it and writes it into Room; Room invalidation causes Paging to create a fresh source and present updated rows. The mediator does not normally push a second independent list straight to the adapter. Google's network-and-database Paging guide documents this flow and the role of REFRESH, APPEND, and PREPEND.
Define a feed identity before writing code. For example, FeedKey(userId, query, sortOrder) separates “my follows, newest first” from “technology, popular first.” Both rows and remote keys should be scoped to that identity. Otherwise a cursor fetched for one query can be accidentally reused for another. Define deterministic local ordering, such as server rank plus stable article ID as a tie-breaker, so a Room invalidation does not arbitrarily reshuffle equal-ranked records.
The local schema can be conceptual rather than production-ready:
| Table | Essential fields | Why it matters |
|---|---|---|
feed_item | feed identity, article ID, sort position, content/version | Stable uniqueness and deterministic display |
remote_key | feed identity, generation, next cursor, end marker | The next request must match the displayed generation |
feed_meta | feed identity, last successful refresh, generation | Cache policy and recovery after process death |
Use a unique constraint on (feed identity, article ID) or an equivalent normalized membership table. A server may repeat a record at a page boundary; the database should not create duplicate memberships. Upsert behavior also needs an explicit policy when the same article changes content or rank.
Work through the stale-key timeline
This small timeline is the main exercise. The token names and article IDs are invented; they illustrate the contract rather than Android library internals.
| Moment | Room items | Stored next cursor | Network action | Correct outcome |
|---|---|---|---|---|
| T0 cached | A, B, C, D | K2 for generation 7 | None; user is offline | Render cache and indicate stale/offline only if product policy requires it |
| T1 refresh starts | Same visible cache | K2 still stored | Request first page without append cursor | Keep cache visible or show full-screen loading according to product policy |
| T2 refresh returns | New page X, A | Server returns K1-new | Transaction replaces or reconciles feed rows and key; generation becomes 8 | X, A is consistent with K1-new |
| T3 append | X, A | Read K1-new | Request next page | Upsert B, C once, store returned key atomically |
The bad sequence is easy to articulate: refresh writes X, A but leaves K2, or an append reads K2 before refresh commits and later writes results from generation 7 into generation 8. That can replay C, D, skip B, or advance to an unrelated server position. A uniqueness constraint prevents a duplicate row but does not prove no gap or correct ordering. You need a generation-aware key policy and a transaction boundary.
Google's RemoteMediator reference states that a launched initial refresh blocks mediator append and prepend until refresh succeeds. Still, your application needs to keep its own row/key updates coherent, especially across failures, process restarts, query changes, and server cursor semantics. Do not claim the library can repair a backend cursor that no longer describes the same feed.
Choose refresh semantics before writing a transaction
For a feed where the backend's first page defines the authoritative current window, a simple strategy is to clear old feed memberships and keys for that feed identity and insert the new page and its cursor inside one Room transaction. This avoids pairing new rows with an old cursor. It can also remove offline history; whether that is acceptable is a product requirement, not a Paging default. If older pages must remain available offline, reconcile by stable ID and maintain a server-supported continuation model that can bridge refresh generations. That is harder and must be tested with inserts, deletes, and ranking changes.
For APPEND, read the cursor for the current feed identity and generation, fetch a page, then transactionally upsert the returned memberships and replace the key with the server's next cursor. Do not advance the key if the row write fails. If the page is replayed after a retry, idempotent row writes and key handling should yield the same state. If the server returns no next cursor, mark end-of-pagination for that generation; if it returns an empty page with a next cursor, follow the backend contract rather than equating “empty” with terminal. Bound repeated empty or repeated-token loops with diagnostics.
For PREPEND, do not invent a request. A newest-first feed that always refreshes from the newest page may report no prepend, while a bi-directional history view needs a separate previous-cursor design. For REFRESH, use the backend's documented initial request (often a null cursor), not a stale append key. The Android guide's examples show one possible key strategy; the server's paging contract decides yours.
Pseudo-code communicates the important boundary without pretending to be a complete drop-in RemoteMediator implementation:
when (loadType) {
REFRESH -> {
val page = api.firstPage(feed)
db.withTransaction {
val generation = feedDao.nextGeneration(feed)
feedDao.replaceMemberships(feed, page.items, generation)
keyDao.replace(feed, generation, page.nextCursor)
feedDao.markSuccessfulRefresh(feed, generation, clock.now())
}
Success(endOfPaginationReached = page.nextCursor == null)
}
APPEND -> {
val key = keyDao.current(feed) ?: return Success(true)
val page = api.after(feed, key.cursor)
db.withTransaction {
// Reject a result from an obsolete refresh generation.
if (keyDao.current(feed)?.generation != key.generation) return@withTransaction
feedDao.upsertMemberships(feed, page.items, key.generation)
keyDao.replace(feed, key.generation, page.nextCursor)
}
Success(endOfPaginationReached = page.nextCursor == null)
}
PREPEND -> Success(endOfPaginationReached = true)
}
Treat that as design sketch, not copy-paste production Kotlin: the APPEND branch must distinguish a rejected stale result from a committed success and decide whether to retry after a generation change. Also ensure the transaction's generation check and key replacement cannot race with a concurrent refresh. A robust implementation can serialize operations per feed or perform a compare-and-swap-style conditional update inside the transaction. Network calls stay outside the database transaction; keep locks short.
Explain load states without lying to the user
Paging exposes Loading, NotLoading, and Error per load type, with separate source and mediator states when a mediator exists. The adapter's loadStateFlow is synchronized with what the adapter has applied to the UI. The convenience refresh, append, and prepend states generally defer to the mediator when present; use source and mediator when the distinction matters. This is documented in Android's load-state guide.
An initial refresh with zero cached rows can justify a full-screen progress indicator. A refresh with cached rows can keep those rows visible, with a less disruptive progress affordance; this is a product choice. An append error should normally show a footer retry, not erase content already read. A refresh error with cached data can preserve the cache and disclose that it may be stale. A refresh error with no data needs an error/empty distinction. An empty-state message should wait until the relevant refresh has completed and the adapter actually has zero items; showing “no results” while a mediator is still loading is misleading.
The UI should not label NotLoading as proof of global network freshness. It means that load path is not loading and has no error at that moment; cache validity is a separate policy. Record the last successful refresh and any offline indicator in app state. A manual adapter.refresh() starts a new load generation, while adapter.retry() retries failed loads in the current generation; choose the action based on the user's intent and explain it.
Test the race, not just the happy path
Build a fake backend that returns page 1 for generation 8 (X, A, K1-new) and page 2 (B, C, K2-new), plus an old generation 7 page with overlapping C. Suspend requests at controlled points. Assert the database rows and remote key together after every release, not merely what the adapter happens to show. Test process death after network success but before transaction commit: old rows and key should remain together. Test transaction failure after partial inserts: rollback should leave the old state.
Then start an append, pause its response, run a successful refresh, and release the old append. Your policy should reject or safely reconcile that obsolete result. Verify that two retries do not produce duplicate memberships or advance past a missing page. Change the feed filter and confirm the old cursor is never used under the new identity. Finally, exercise offline first launch with cache, offline first launch without cache, refresh error after cache, append error after two pages, empty response with another cursor, and repeated cursor.
At the UI level, collect loadStateFlow in the right lifecycle scope, inspect itemCount, and test the difference between a header refresh affordance and footer append retry. Run at least one integration test with Room invalidation and the actual adapter because a pure mediator unit test cannot prove that the visible list, scroll position, and load-state banners update as intended. Measure duplicate IDs and refresh latency as production diagnostics, but never use an alert count as the only correctness check.
Practice related interview questions
| Question | Why it helps this exercise |
|---|---|
| Design paginated list UI and pagination API | Stable order and changing page boundaries |
| Design a large-scale news app with caching | Cached feed and offline read state |
| Debug Event Search, Pagination, and Redis Invalidation | Stale cache and pagination diagnosis |
| Implement Stateful Fetch-N over a Paginated Upstream | Retry and token invariants |
| Design a Performant Frontend News Feed | List rendering and client-side feed behavior |
To rehearse, draw T0 through T3 without notes. If your answer cannot say which generation owns each row and cursor after every failure, revisit the transaction and retry policy. Then practice the paginated-list design prompt and explain where Android Paging adds a local database and load-state boundary.