Mobile Design Rounds
GuideAndroidiOS7 min read

Cursor vs offset paging on mobile: why page numbers break

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: The five-step method · Staff-level guide.

In short: Page numbers break when the list changes while the user scrolls. A cursor keeps the place, so I choose it for any feed that changes. [Opinion]

Tags. Every claim below has one of three tags. A [Verified: source] tag means I read it on the official page on 14 Oct 2026. A [Reported by source] tag means it is a company or secondhand statement. An [Opinion] tag marks my own judgement. The sources are linked at the end.

The question

A user scrolls a feed. The app has loaded two pages. New posts keep arriving. How does the app ask for page three?

Many people answer with a page number. The app sends "page 3, 10 items per page". This is called offset paging, because the server skips the first 20 items. The server counts rows from the top of the list at that moment.

That answer works for a list that never changes. A feed changes all the time. The count of rows at the top is a moving target. [Opinion]

Here are the two failures. Both come from the same cause: the server counts from the top, and the top moves.

  • A new item arrives at the top. Every old item moves down one row. The last item of page two is now the first item of page three. The user sees it twice.
  • An item is deleted. Every item below it moves up one row. The first item of page three is now the last item of page two. The user never sees it.

You can try both in the pagination race. It is a small tool that runs in your browser. Press the buttons and watch the repeats and the missed posts. [Opinion]

A cursor is a saved place in the list. The server gives it to the app with each page. The app sends it back to ask for the next page. The app does not read it or change it, so I call it opaque. The server decides what is inside.

The server can put an item ID and a time inside the cursor. Then it asks for "the 10 items after this one". A new item at the top does not change which items come after that one. A delete above it does not change that either. [Opinion]

Offset and cursor side by side

QuestionOffset (page number)Cursor (saved place)
What does the app send?A number, such as page 3The text the server gave it
A new item arrives at the topAn item repeatsNo repeat
An item above is deletedAn item is missedNo miss
Jump to page 40EasyNot possible without a trick
Work for the databaseSkips rows, so deep pages get slowerStarts at a known place
Ranked feed that re-ranksRepeats and missesStill needs care
What the server must keepNothingA stable order and a safe cursor

The row about the database is a common claim in backend writing. I did not test it for this guide. [Opinion]

When I would choose which

I choose a cursor when the list changes while the user reads it. News feeds, chats, notifications and order lists all do this. I also choose a cursor when the list is long and the user only moves forward. [Opinion]

I choose offset when the list is short and stable, and the user needs page numbers. An admin table is a good example. A search result page that says "page 40 of 200" is another. [Opinion]

A cursor has three costs. I would say all three in an interview.

  1. No jump to page 40. A cursor only knows "after this item". To reach page 40, the app must walk through 39 pages, or the server needs a second way to look up a place.
  2. A ranked feed needs care. The server may re-rank posts between two requests. A post can then move from page three to page one, or the other way. A cursor does not stop this. The server must keep the ranked list fixed behind the cursor, or the app must remove repeats by post ID. I would do the second even if the server does the first. [Opinion]
  3. The cursor must be stable. It must stay valid for some time. It must not depend on a row number. The server also needs a rule for an old cursor that has expired. A clear error that tells the app to refresh from the top is a good rule. [Opinion]

The news feed case shows the first design I would draw. The pagination library case shows how a library holds all three styles of key. [Opinion]

Mistakes I see

This is my reading of what people write and say. I make no claim about how often it happens. [Opinion]

  • Using the item number as the key. The design says "next page is page + 1". The first new post breaks it.
  • Making the cursor readable. The app reads the cursor and builds its own. Then the server cannot change the format later.
  • Forgetting to remove repeats. A cursor does not fix a re-rank. The app should still keep one row for each post ID.
  • Sending two requests for one page. The user taps twice, or scrolls fast. Two loads start for the same cursor. One request at a time for each direction avoids this.
  • No plan for an expired cursor. The app shows an error forever. A refresh from the top would fix it.

On Android

Google's Paging 3 library loads data in pages from a local database or from the network. Verified: [Paging library overview, 14 Oct 2026]

The key type is yours to choose. The official page says that the PagingSource<Key, Value> type has two type parameters. The key is the identifier used to load the data. Its example uses Int page numbers. Verified: [Paging data guide, 14 Oct 2026]

So a cursor fits. The Key can be a String. The app stores the next key and passes it to the next load. I read the page numbers in the official example as a teaching choice. [Opinion]

The same page lists three results for a load. A LoadResult.Page means success. A LoadResult.Error means failure. A LoadResult.Invalid means the source is no longer valid and a new one should replace it, for example after a data change. Verified: [Paging data guide, 14 Oct 2026]

I would return Invalid for an expired cursor. That is my mapping, not a rule from the docs. [Opinion]

The page also says a PagingSource must implement getRefreshKey. It returns the key to use when the data is refreshed or invalidated after the first load. Verified: [Paging data guide, 14 Oct 2026]

This is the hard part with cursors. A page number is easy to compute again. A cursor is not. So many designs store the cursor with each row in the database.

The overview also says Paging has built-in request deduplication. That helps with the double-tap problem. It does not remove repeated posts from a re-rank. Verified: [Paging library overview, 14 Oct 2026]

On iOS

Apple has no paging library that matches Paging 3. The system gives you a hook for loading early, called prefetch. The UITableViewDataSourcePrefetching protocol gives a table view advance warning of the data it needs. This lets you start long data work early. Verified: [UITableViewDataSourcePrefetching, 14 Oct 2026]

The table view calls the prefetch method as the user scrolls. It lists the rows the user will likely see soon. The list is sorted so that the row closest to the screen comes first. Verified: [tableView(_:prefetchRowsAt:), 14 Oct 2026]

Apple says the table view does not call it for every row. Your code must also fetch data by itself when a row needs it at once. Verified: [tableView(_:prefetchRowsAt:), 14 Oct 2026]

The cancel method tells you when the data is no longer needed. Verified: [tableView(_:cancelPrefetchingForRowsAt:), 14 Oct 2026]

The collection view has the same protocol, UICollectionViewDataSourcePrefetching. Verified: [UICollectionViewDataSourcePrefetching, 14 Oct 2026]

Prefetch tells you which rows are near. It does not tell you which cursor to use. You map the row to the end of the list, and then ask the server for the next page with the saved cursor. Your code keeps one request running. This mapping is my design. [Opinion]

See it in a case

Words used here

  • Paging: loading a long list in small parts.
  • Cursor: a saved place in a list.
  • Prefetch: loading data before the user asks for it.
  • Dedupe: keeping only one copy of the same item.
  • Source of truth: the one copy of the data that the app trusts.

Sources

Words on this page (4)
Cursor
A saved place in a list. The server gives it to the app, and the app sends it back to get the next part.
Dedupe
To keep only one copy of the same item. For example, a message that arrives twice is stored once.
Paging
Loading a long list in small parts. The app asks for one page at a time.
Prefetch
Loading data before the user asks for it. It makes scrolling smooth, but it can waste data.

Read the whole glossary

Verification notes (last reviewed 14 Oct 2026)

on 2026-10-14 I fetched the raw text of the two developer.android.com pages (Paging library overview and the Paging data guide) with curl and read them. I also fetched the Apple documentation JSON for UITableViewDataSourcePrefetching, tableView(_:prefetchRowsAt:), tableView(_:cancelPrefetchingForRowsAt:) and UICollectionViewDataSourcePrefetching. I checked: the Key and Value type parameters, the Int page number example, the three LoadResult forms, the getRefreshKey requirement, built-in request deduplication, and the Apple prefetch behaviour (called as the user scrolls, closest row first, not called for every row, cancel method). I checked the pagination race page on this site for the two failures and the double tap. Not verified: the claim that deep offset pages are slower in the database (stated as a common claim, not tested); any statement about how a particular company pages its feed; whether Paging 3 supports jumping to a far page (I did not read the PagingSource reference for this); the guidance that Invalid fits an expired cursor (my mapping). I did not check Apple's SwiftUI or diffable data source paging advice. The date is 2026-10-14.

Keep reading

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.