Mobile Core Concepts
Pagination
Why an infinite scroll needs a cursor and a cache, and the pagination pattern that keeps a feed stable and smooth under inserts.
Every app that scrolls a list eventually paginates: a feed, a chat backlog, a search result page, a contacts list. The naive ?offset=20&limit=20 shape is almost always wrong, because the dataset underneath is not static - posts get inserted at the top, messages stream in, items get deleted. The standard modern answer on Android is two layers stacked: an opaque server-side cursor that survives mutation, and Paging 3 + RemoteMediator on the client that makes the Room cache the source of truth and the network just a supplier. This page covers both sides of that seam. Caching policy lives in Caching Strategies; how Room and withTransaction work mechanically lives in Persistence; this entry assumes both.
Why offset pagination drifts
The feed has 1,000 posts. The client fetches page 1 with ?offset=0&limit=20 at t=0. Between t=0 and t=5 the server accepts 5 new posts at the top. The client fetches ?offset=20&limit=20 at t=5. The server now has 1,005 rows; rows 20-39 of the current feed are what used to be rows 15-34. The client renders posts 16-20 twice and silently never sees what should have been at the old positions 36-40.
The bug is invisible on a static dataset and reliable on any dataset with inserts. Client-side dedupe requires comparing IDs across every cached page on every fetch, and still cannot recover the skipped items.
The fix is to stop indexing by position into a presentation and start indexing a position in the dataset itself. Cursors encode "where am I" in a form stable under inserts and deletes, because the encoding is rank-relative, not count-relative.
Cursor design
Three flavors, recommended-first. Default to option 1; the others exist for specific reasons covered below.
1. Opaque server-monotonic cursor (the default)
The server returns a base64 blob with each page; the client echoes it back on the next request. Internally the cursor encodes whatever the server needs to resume the query: (rank_score, post_id) for ranked feeds, (created_at, post_id) for chronological, plus optionally a snapshot id so retries return the same page. The contract is monotonicity: any item ranked after a cursor at fetch time stays after it for the pagination session. Inserts at the top get a newer score, so they sit ahead of the cursor; the page-2 request returns what it would have a moment ago.
GET /v1/feed?cursor=eyJ0cyI6MTcwOTEyMzQ1NiwiaWQiOiJwXzQyIn0
200 OK
{ "items": [...20 posts...], "nextCursor": "eyJ0cyI6MTcwOTEyMjAwMCwiaWQiOiJwXzIyIn0" }The server constructs that cursor like this:
// GET /v1/feed?cursor=<base64url(JSON{ts,id})>
const c = req.query.cursor
? JSON.parse(Buffer.from(req.query.cursor, "base64url").toString())
: null;
const rows = await db.query(
// (ts, id) tuple compare stays stable under timestamp ties.
// LIMIT 21 = 20-item page + 1 probe row; DB never returns more than 21 rows.
`SELECT * FROM posts WHERE ($1::timestamp IS NULL OR (created_at, id) < ($1, $2))
ORDER BY created_at DESC, id DESC LIMIT 21`,
[c?.ts ?? null, c?.id ?? null]
);
// base64url(JSON) wrap keeps the cursor opaque - server can later swap to (rank_score, id).
const items = rows.slice(0, 20);
const last = items.at(-1);
const nextCursor = rows.length > 20
? Buffer.from(JSON.stringify({ ts: last.created_at, id: last.id })).toString("base64url")
: null;
return { items, nextCursor };2. Keyset pagination
Structurally identical to option 1, just non-opaque. The cursor's shape is part of the API: WHERE (created_at, id) < (:cursor_ts, :cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20. Clients can read, log, and share it. The cost is that the server must validate every cursor as untrusted input, and a schema change forces a client release. Useful when humans need to see cursors (admin tools, shareable permalinks); risky as the default for app feeds.
3. Offset pagination - antipattern
offset=20, two new posts inserted at the top mean page 2 returns posts that were on page 1 (duplicates) and silently skips one. Acceptable only for genuinely immutable datasets - a paginated audit log, a static catalog. See § Why offset pagination drifts above for the full demonstration.| Property | Opaque cursor | Keyset | Offset |
|---|---|---|---|
| Stable under top inserts | Yes | Yes | No |
| Random page-N jumps | No | No | Yes |
| Survives deletes mid-iteration | Yes | Yes | Yes (skips) |
| Cursor is human-readable | No | Yes | Yes |
| Server can change encoding without client release | Yes | No | - |
Summary. Default to opaque cursors; the server keeps encoding flexibility and the client only needs to know "echo the token back". Reach for keyset only when humans need to read cursors (admin tools, shareable permalinks). Use offset only for immutable datasets (audit logs, static catalogs) - on anything user-generated it silently corrupts pagination under inserts.
Deletion-during-iteration is the case candidates worry about and shouldn't. A cursor of (ts=12:00, id=p_42) is a comparison key, not a row reference; deleting p_42 between fetches doesn't invalidate it because the next WHERE clause is "items ranked after this point", still answerable. The server returns nextCursor: null when exhausted; that null is the only end-of-feed signal the client should trust.
Response shape: prefer { items, nextCursor } over { items, hasMore }. The cursor is more information - you can resume from it; hasMore is one bit. For bidirectional pagination (chat scrolling both ways), the response carries both prevCursor and nextCursor.
Paging 3 client
Without help, an infinite-scroll list is a state machine, not a feature: track loaded pages, detect when the user nears the end and load the next, cancel in-flight loads on config change, coordinate with a cache so rotation doesn't refetch, retain scroll position through restoration, surface loading and error state to the UI. Writing that per-screen is what makes feed implementations buggy.
Paging 3 hides the state machine behind a single Flow<PagingData<T>>. The UI collects the flow and items appear as the user scrolls; the library calls back to your data source when more data is needed. Scroll-position retention, in-flight cancellation, page deduplication, and wiring into LazyColumn are no longer your problem.
The data-source side is a one-liner. Almost every modern feed uses Room as the local source of truth, and Room generates a PagingSource for you - any DAO query that returns PagingSource<Int, T> works:
@Dao
interface PostDao {
@Query("SELECT * FROM posts ORDER BY rank DESC, id DESC")
fun pagingSource(): PagingSource<Int, PostEntity>
}Three more pieces complete the picture. The Pager is the orchestrator: it owns the loading state machine and exposes the Flow<PagingData<T>> the UI collects. PagingConfig holds the tuning knobs - page size, prefetch distance, placeholder strategy. RemoteMediator (optional) is the cache-fill adapter: when the cache runs dry, the Pager asks it to fetch from the network and write to Room. The next section covers the mediator in detail.
The whole wiring is one block:
fun feedFlow(): Flow<PagingData<Post>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
remoteMediator = FeedRemoteMediator(api, db),
pagingSourceFactory = { db.postsDao().pagingSource() },
).flow.map { paging -> paging.map { it.toPost() } }The PagingConfig parameters:
| Parameter | Default | Meaning |
|---|---|---|
pageSize | required | Items per backend page. Tune for round-trip cost vs first-screen latency. |
prefetchDistance | pageSize | How many items ahead of the visible window to start loading. |
initialLoadSize | 3 * pageSize | First fetch is larger to fill more than one screen. |
enablePlaceholders | true | Ghost rows for not-yet-loaded items; requires a known total count. Default to false for variable-height feeds where the total is unknown. |
Pager.flow is a cold flow: each new collector starts a fresh PagingSource and reloads from page 1. cachedIn(viewModelScope) caches the data for the scope's lifetime so multiple collectors (the original screen plus any re-subscription after recomposition) share one underlying source - without it, every recomposition that re-subscribes triggers a fresh load.
RemoteMediator: cache as source of truth
Paging 3 binds the UI to a single PagingSource. Real apps want two tiers: a local Room cache for offline reads and fast cold start, plus the network for fresh data. RemoteMediator<K, T> is the bridge - the UI's PagingSource still reads from Room; the mediator detects when the cache is missing data the UI asked for, fetches from the network, and writes to Room transactionally. The UI never sees the network directly; it sees Room's Flow re-emit. That is what "cache as source of truth" means.
The contract is one method: suspend fun load(loadType: LoadType, state: PagingState<K, T>): MediatorResult. LoadType is one of three values:
REFRESH- the UI wants the head of the dataset. Fires on cold start, onPager.refresh()(pull-to-refresh), and onPagingSource.invalidate().APPEND- the UI is scrolling forward,prefetchDistanceitems from the end of the cache.PREPEND- the UI is scrolling backward past the head. Forward-only feeds returnSuccess(endOfPaginationReached = true).
The mediator's contract: fetch a page from the network, write it to Room, update the cursor lookup table. The UI doesn't see the network call; it sees Room's Flow emit again with the new rows.
The cursor lookup table - remote_keys - is keyed by item id and stores the cursor pointing at the next page. When APPEND fires, the mediator reads state.lastItemOrNull(), looks up its nextCursor, and asks the server for the page after it.
override suspend fun load(loadType: LoadType, state: PagingState<Int, PostEntity>): MediatorResult {
val cursor = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> db.remoteKeysDao().lastKey()?.nextCursor
?: return MediatorResult.Success(endOfPaginationReached = true)
}
val response = api.fetchFeed(cursor)
db.withTransaction {
if (loadType == LoadType.REFRESH) { db.postsDao().clear(); db.remoteKeysDao().clear() }
db.postsDao().insertAll(response.items.toEntities()) // INSERT OR REPLACE
db.remoteKeysDao().insertAll(response.items.map { RemoteKey(it.id, response.nextCursor) })
}
return MediatorResult.Success(endOfPaginationReached = response.nextCursor == null)
}The withTransaction is non-negotiable. Writing the page and the cursor in separate transactions opens a window where the UI sees new posts whose nextCursor points at the previous page's end - the next APPEND re-fetches or skips. One transaction means both writes land or neither does.
INSERT OR REPLACE keyed on postId makes cross-page duplicates safe. A retry or a server-side snapshot churn can return the same post twice; the upsert is a no-op and the LazyColumn's stable keys ensure it renders once.
Why this pattern wins, in three lines: offline reads work because the cache is always there; config changes don't refetch because Room is process-singleton and the Flow survives; pull-to-refresh is Pager.refresh() -> mediator REFRESH -> swap head in Room -> LazyColumn diffs against stable keys.
Refresh and scroll position
The non-obvious win of the pattern: when REFRESH lands new items at the head of the feed, the user's viewport doesn't jump. Three pieces produce this together:
- The data model - each
PostEntitycarries@PrimaryKey val postId: String. Room'sPagingSourceemits the same postId for the same row across every emission; that stable identity is what the rest of the chain hangs on. - The
LazyColumnkey lambda -items(pagingItems, key = { it.postId })tells the list that postId, not list index, is the diff key. Without it the rest of the section is wrong. LazyListState- the scroll state object. After the diff,LazyColumnfinds the previously-anchored item in the new list by its key, writes its new position intofirstVisibleItemIndex, and re-measures. The viewport renders the same pixels even though the underlying index moved (say from 12 to 17 because 5 new posts arrived above).
key lambda (or using the list index) - LazyColumn falls back to positional diff: every row re-binds, scroll resets to the top, the user loses their place on every refresh.The scrollbar follows the new total content height. Android's LazyColumn doesn't render a system scrollbar by default - the platform pattern is "no scroll chrome unless you add one." If you do, the thumb reflects that the user is now further from the top (more rows above them). The standard UX cue for the new items isn't the scrollbar though - it's a "X new posts" pill that overlays the list and dismisses on scroll-up.
Where the cursor lives
Three storage answers depending on the shape of the pagination.
1. Paging 3 with RemoteMediator
Cursor lives in the Room remote_keys table, per page boundary. Survives process death because Room is on disk; reattaches automatically on next cold start because the mediator reads the last key on the next APPEND.
2. Single-screen pagination without Paging 3
Rare, but appropriate when the dataset is small (a paginated search modal that loads at most 3 pages of 20 results). Cursor lives in SavedStateHandle to survive process death without the Room overhead. See State Management for the lifetime rules.
3. "Last seen" cursor for sync
In a messenger, the cursor is per-chat in Room and represents "the newest message I have seen." On reconnect the client sends it to the server's catchup endpoint. The pagination is upstream from this entry (it's reconnect/sync, not user scrolling), but the storage answer is the same: durable, per-key, atomic with the data it indexes.
Mixed-source feeds
The cursor model handles server-injected items naturally. A sponsored post inserted by the server at position 7 of page 2 is just an item in the page response; its cursor neighborhood is identical to any other item. The client doesn't know it was injected.
Client-injected items - "New since you were last here" dividers, date headers - are a separate concern. Paging 3 provides PagingData.insertSeparators { before, after -> Separator? }, which runs after the mediator and produces separators from adjacent pairs.
pagingData.insertSeparators { before, after ->
if (before != null && after != null && newDay(before.createdAt, after.createdAt))
DayHeader(after.createdAt) else null
}What the cursor model cannot express is client-side reordering - "show my pinned posts first, then the feed." That's a UI-layer concern; do it with a separate LazyColumn section or combine over two flows. The paged flow stays in server order.
Bidirectional pagination
For chat backlog, both APPEND (older messages on scroll up) and PREPEND (newer messages on reconnect after a backgrounded WebSocket) are real loads. The cursor response shape grows to (prevCursor, nextCursor); the remote_keys row per message stores both. On reconnect the client paginates PREPEND from its newest known message until the server returns an empty page, closing the gap. Messenger covers the full reconnect-and-catchup pattern; here the only new piece is that PREPEND is a live LoadType and the mediator handles it like APPEND with the opposite cursor.
See also
- Network Protocols - the HTTP/REST substrate the pagination requests ride on; this page assumes a working
GETwith query params and JSON responses. - Caching Strategies - SWR, ETags, and the cache-vs-network race; pagination layers on top of those primitives and inherits their semantics for the head of the feed.
- Persistence - Room,
PagingSource<Int, T>, andwithTransactionare introduced there; this page uses all three. - Concurrency -
PagingDatais aFlow; the same cancellation and scope rules apply, andcachedIn(viewModelScope)is the standard guard. - State Management -
SavedStateHandlefor cursor survival across process death in the non-Paging-3 case.
Used in
- Newsfeed App - the canonical use case; cursor pagination and Paging 3 + RemoteMediator are the core deep dives. Pull-to-refresh and head-replacement-without-scroll-loss come directly from this pattern.
- Messenger App - chat backlog uses bidirectional pagination;
PREPENDis a real load type for reconnect catchup. The "last seen" cursor lives in Room per-chat and indexes the gap-recovery endpoint. - Photo Gallery App - the gallery grid pages over
MediaStorequeries with cursor + Paging 3; thePagingSourceis custom (wraps an AndroidCursor) because the source is not Room. - Doc Editor App - the document list (recent, shared with me) is a paged feed over a cloud API; the same RemoteMediator pattern applies, with Room as the local cache.
Done reading? Mark it so it sticks in your dashboard.