← All problems

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.

Free~20 min

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

Warning
Listed only to be explicit that it is the wrong default. By the time the client requests 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.
PropertyOpaque cursorKeysetOffset
Stable under top insertsYesYesNo
Random page-N jumpsNoNoYes
Survives deletes mid-iterationYesYesYes (skips)
Cursor is human-readableNoYesYes
Server can change encoding without client releaseYesNo-

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.

Note
Never re-parse the opaque cursor on the client - the entire point of opacity is that the server can change the encoding without a client release. Treat it as a token to echo back; decoding it client-side, even for logging, couples you to both sides of the format.

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:

ParameterDefaultMeaning
pageSizerequiredItems per backend page. Tune for round-trip cost vs first-screen latency.
prefetchDistancepageSizeHow many items ahead of the visible window to start loading.
initialLoadSize3 * pageSizeFirst fetch is larger to fill more than one screen.
enablePlaceholderstrueGhost 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:

  1. REFRESH - the UI wants the head of the dataset. Fires on cold start, on Pager.refresh() (pull-to-refresh), and on PagingSource.invalidate().
  2. APPEND - the UI is scrolling forward, prefetchDistance items from the end of the cache.
  3. PREPEND - the UI is scrolling backward past the head. Forward-only feeds return Success(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.

Rendering diagram…

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:

  1. The data model - each PostEntity carries @PrimaryKey val postId: String. Room's PagingSource emits the same postId for the same row across every emission; that stable identity is what the rest of the chain hangs on.
  2. The LazyColumn key 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.
  3. LazyListState - the scroll state object. After the diff, LazyColumn finds the previously-anchored item in the new list by its key, writes its new position into firstVisibleItemIndex, 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).
Warning
Dropping the 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 GET with 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>, and withTransaction are introduced there; this page uses all three.
  • Concurrency - PagingData is a Flow; the same cancellation and scope rules apply, and cachedIn(viewModelScope) is the standard guard.
  • State Management - SavedStateHandle for 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; PREPEND is 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 MediaStore queries with cursor + Paging 3; the PagingSource is custom (wraps an Android Cursor) 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.