Pagination From the Interface Backwards
Offset gives you jump-to-page and gives you duplicates under insertion. Cursor gives you stability and takes away page numbers. Pick from the UI you owe, not from the API you were given.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Offset or cursor — which one does the interface I am building actually require, and what does the other one make impossible?
Someone is looking through a long list. They want to find one thing, go into it, come back, and still be where they were. They do not care what a cursor is.
Ask for page one, render it, add a Next button that asks for page two. Offset and limit are two integers, every API supports them, and page numbers are what people expect from a list.
Under insertion, offsets shift. A row added at the top of the sort order pushes everything down by one, so page two now begins with the row that ended page one — the user sees a duplicate and never sees the row that got skipped (Offset Pagination: Simple, Jumpable, and Lying Under Writes in API Design).
- Under insertion, offsets shift. A row added at the top of the sort order pushes everything down by one, so page two now begins with the row that ended page one — the user sees a duplicate and never sees the row that got skipped (Offset Pagination: Simple, Jumpable, and Lying Under Writes in API Design).
- Deep offsets get slower.
OFFSET 10000usually means the database produced ten thousand rows and threw them away, so page five hundred is dramatically more expensive than page one (Pagination That Survives a Large Table in Backend). - Infinite scroll over offsets accumulates that error: by the twentieth page the list contains repeats the user can see, and blaming the data is the natural first reaction.
- Coming back from a detail view puts you at the top. The list state — which pages were loaded, how far you had scrolled — lived in a component that unmounted (Scroll Restoration).
- Appending rows without reserving space moves the scroll position under the user's finger mid-read, which is the most visceral version of a layout shift (Visual Stability).
What is actually happening
In the browser, not in the framework.
- Offset addresses a position in a result *set*: skip N, take M. The address is relative to a set that is being mutated by other people while the user reads it, which is why it drifts.
- Cursor addresses a position in a result *sequence*: everything after this opaque marker, ordered by a stable key. The marker is anchored to a row, so insertions elsewhere do not move it (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design).
- Cursor pagination requires a total order the server can resume from — usually a sort key plus a tiebreaker like an id. Without the tiebreaker, rows sharing a sort value can be skipped or repeated at the boundary.
- Offset can answer "how many pages are there" and "take me to page 40". Cursor structurally cannot: there is no way to name a position you have not walked to (Unbounded Collections: The Anti-Pattern With a Fuse in API Design).
- Both are affected by mutation, differently. Offset repeats and skips; cursor gives you a stable window but new rows appear only above your starting point, so the user may not know they exist until they refresh.
- Infinite scroll is not a pagination strategy — it is a UI over one. It adds requirements the strategy has to meet: stability under insertion, restoration on back, and a way to reach the end of the page (Keyboard Operability).
What this makes the browser do
And which of it is avoidable.
- Appending rows grows the DOM without bound. A thousand rows is a thousand elements to style, lay out, paint and keep in memory for the rest of the session (What a Mutation Costs).
- Style and layout cost scale with node count, so the *twentieth* page of an infinite list is slower to append than the first even though the payload is identical.
- Virtualisation caps the rendered node count at what fits the viewport, and pays for it with a scrollbar that has to be estimated and content that is absent from find-in-page (List Virtualization).
- Restoring scroll position after a back navigation requires the same number of rows to exist again at the same heights, which is why restoration and virtualisation interact badly (Scroll Restoration).
- Avoidable: re-rendering the whole list to append to it. Keyed reconciliation over a stable list appends without touching existing rows (Reconciliation and Keys).
The same list, addressed two ways
The difference is not syntax. Offset names a position in a set that other people are changing; cursor names a row and asks for what follows it. Everything else in this lesson falls out of that one distinction — including the two capabilities each one permanently denies you.
The scenario below is the one that produces the bug report. Twelve rows, four per page, and someone inserts a new row at the top between the first request and the second.
GET /orders?sort=-created&offset=0&limit=4 → [ O9, O8, O7, O6 ] page 1 rendered ← someone creates O10 (now first in sort order) GET /orders?sort=-created&offset=4&limit=4 → [ O6, O5, O4, O3 ] page 2 O6 appears twice. O10 is never shown. The user sees a duplicate and reports "the data is wrong". Deep pages get slower: offset=10000 means the server produced 10,000 rows and discarded them.
GET /orders?sort=-created&limit=4
→ { items: [ O9, O8, O7, O6 ], next: "c:O6" }
← someone creates O10 (now first in sort order)
GET /orders?sort=-created&after=c:O6&limit=4
→ { items: [ O5, O4, O3, O2 ], next: "c:O2" }
No duplicate, no skip: the window is anchored to O6.
O10 exists above the window — surface it deliberately
("1 new order") rather than pretending it is not there.
Cost: there is no page 7 to link to, and no total count.The cursor is anchored to a row, so concurrent insertion elsewhere in the sequence cannot move it; the offset is anchored to a count, and a count is exactly what an insertion changes. That is also why the cursor cannot answer "take me to page 40" — it has no way to name a position it has not walked to (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design).
What the interface owes, and which strategy can pay
This is the table to bring to the API conversation. Each row is a thing a user or a product manager will ask for, and the honest answer for one of the two strategies is "not possible" rather than "not implemented yet" — which is a much better conversation to have before the endpoint exists (How API Shape Drives UI Complexity).
Note the last two rows. Both strategies need help from the client to restore position on back, and both need the pagination state in the URL to survive a refresh. Neither one gives you that for free.
| What the UI needs | Offset | Cursor | Why |
|---|---|---|---|
| Jump to page 40 | Yes | Not possible | A cursor names a row you have reached; page 40 is a position in a set, which only a count can address |
| "214 results" total count | Usually | Rarely | Counting requires scanning the set; cursor APIs commonly omit it deliberately because it is the expensive part |
| A shareable link to a page | Yes — ?page=7 | Only to a walked position | The cursor is opaque and anchored; it is shareable but it does not mean "the seventh page" to anyone |
| Stable list while rows are inserted | No | Yes | Insertion changes offsets and does not move a row anchor — the whole point of the mechanism |
| Infinite scroll without duplicates | Degrades | Yes | Offset drift compounds with every appended page; the cursor window does not drift |
| Sorting by a user-chosen column | Yes | Only with a stable tiebreaker | Resuming needs a total order; two rows with the same sort value need an id to break the tie or the boundary repeats |
| Fast deep pages | No | Yes | Deep offsets make the server produce and discard rows; a cursor seeks (Pagination That Survives a Large Table in Backend) |
| Restore position on back | Client work | Client work | Needs pagination state in the URL and the loaded pages cached — neither strategy provides it (Scroll Restoration) |
| Survive a refresh mid-list | Client work | Client work | Only if the page or cursor is in the URL rather than in component state (The URL Is Application State) |
What appending a page costs the browser
Choosing the strategy is half the lesson; the other half is that every page you append is DOM you keep. The costs below are about the *change*, not the request, and they are the reason the twentieth append is slower than the first even though the payload is identical.
The pattern to notice is that appending at the end is cheap and inserting at the top is not — because everything below an insertion has to be positioned again. That asymmetry is why "new items" belong behind an affordance the user activates rather than being spliced in while they read.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Append 20 rows to the end of a list | yes | yes | yes | yes | New boxes must be styled, positioned and painted. Existing rows above are usually untouched, which is what makes appending the cheap direction. |
| Insert 3 rows at the top of a long list | yes | yes | yes | yes | Everything below moves, so layout runs over the whole list and the scroll position shifts under the reader — the reason to put new items behind a "3 new items" control (Visual Stability). |
| Replace the whole list (new filter) | yes | yes | yes | yes | The full cost of the list, plus discarding the old nodes. Keyed reconciliation cannot help when nothing is reused (Reconciliation and Keys). |
| Append with index-based keys | yes | yes | yes | yes | Every row is now associated with different data, so the framework updates all of them instead of adding twenty. The change is small and the work is proportional to the whole list. |
| Toggle a row's selected class | yes | maybe | yes | maybe | Layout only if the rule changes geometry — a border or padding does, a background colour does not. Choose the property with that in mind (The Cost of a Change). |
| Scroll a virtualised window by one page | yes | maybe | yes | yes | Rows are recycled rather than added, so node count is constant; layout depends on whether row heights are fixed or measured (List Virtualization). |
| Show a spinner in reserved space at the list end | yes | no | yes | yes | The space was already allocated, so nothing above it moves — which is the entire reason to reserve it. |
caveat Every maybe here depends on what else is on the page: whether the list is inside a container with its own layout containment, whether rows have fixed heights, and whether the property being changed is one the engine can handle on the compositor. Measure the specific list rather than trusting the row (CSS Containment).
How to build it
Most important first.
- Start from the interface. Does this UI need a page number, a total count, a jump-to-page, or a deep link to "page 7"? If yes, you need offset. If it needs a feed that stays consistent while things are being added, you need a cursor. The API argument follows the UI requirement, not the other way round (How API Shape Drives UI Complexity).
- Put pagination state in the URL. Page number or cursor, plus the filters and sort that define the sequence — this is what makes a list shareable, restorable and back-button-correct (The URL Is Application State).
- Reserve space before the rows arrive. Appending into a container whose height was already accounted for keeps the scroll position stable (Visual Stability).
- Give infinite scroll an explicit "Load more" control as well as an intersection observer. Automatic loading alone is unreachable by keyboard and makes any page footer permanently unreachable (Keyboard Operability).
- Announce the arrival of new rows — "20 more results, 60 of 214" — through a polite live region, not silently (Live Regions and Announcement).
- Cache pages by key so a back navigation restores from memory instead of re-fetching and re-appending. This is the only way "back" feels instant (Query Keys and Invalidation).
- Decide what happens to rows inserted above the window. A "3 new items" affordance is honest; silently never showing them is not (Resynchronisation After a Gap).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A paginated list needs an announced position.
role="status"carrying "Showing 41 to 60 of 214" after each load tells a screen-reader user what a sighted user reads off the scrollbar (Live Regions and Announcement). - Never move focus to the top of the list when a new page loads. Focus belongs where it was — usually on the control the user activated — or on the first newly added row if they explicitly asked for more (Focus Management).
- Infinite scroll must have a keyboard path. An intersection observer is a mouse-and-touch affordance; a real "Load more"
buttonis the version that works for everyone (Semantics Are Behaviour). - Content after an infinitely loading list is unreachable. If there is a footer, either paginate explicitly or move the footer out of the scroll container.
- Page controls are navigation: a
navwith an accessible name, links rather than buttons where each page has a URL, andaria-current="page"on the current one (Accessible Component Patterns).
What can go wrong
- Duplicate rows in an infinite list, caused by offset drift and usually first reported as "the data is wrong".
- A React key derived from the array index, so appending a page re-associates every row with different data and the DOM is rebuilt rather than extended (Reconciliation and Keys).
- Two pages requested at once because the intersection observer fired twice before the first response landed — the classic double-fetch at the bottom of a list (Five Components, One Request).
- Back navigation that loses everything: the list re-fetches page one and the user is at the top, having lost twenty pages of scrolling (Scroll Restoration).
- A footer nobody can reach, because more content is loaded every time the user gets near the bottom.
- The mitigation failing: virtualisation that removes rows from the DOM, breaking find-in-page, breaking screen-reader navigation of the list, and breaking scroll restoration all at once (List Virtualization).
- A cursor stored in component state rather than the URL, so a refresh returns the user to the first page of a list they had walked a long way into.
- Two page requests in flight at once — from a double-firing intersection observer or an impatient user — can resolve out of order, appending page three before page two (Out-of-Order Responses).
- A filter change while a page request is in flight appends rows from the old filter into a list rendered under the new one. The page key must include the filter, and stale arrivals must be dropped (Cancelling a Request Nobody Is Waiting For).
- Rows inserted server-side between two page fetches are the drift itself: the second request is answered against a set that no longer matches the one the first request was answered against (Eventual Consistency in Practice in Backend).
- A cursor is opaque to the user, not to an attacker. If it encodes a row id or an offset in a trivially decodable form, it can be edited — the server must re-authorise every page rather than trusting the cursor it issued (Object-Level Authorization in Backend).
- Total counts leak. "214 results" on a filtered search over a multi-tenant dataset tells the user how many records exist that they cannot see (Tenant Isolation in Backend).
- Deep pagination is an enumeration mechanism. A list that will happily serve page 10,000 is an export API with a slower interface (The Rate-Limit Contract in API Design).
- The URL is shared. Putting a cursor in it means the cursor travels into chat logs and bug reports, which is fine only if it carries no authority of its own (The URL Is Application State).
- "Cursors are the modern way; offsets are legacy." They answer different questions. An admin table with page numbers and a result count needs offset, and no amount of modernity changes that (Pagination: Choosing How Lists End in API Design).
- "Infinite scroll is a pagination strategy." It is a UI. Underneath it is still offset or cursor, and choosing it makes the stability requirements stricter rather than looser.
- "Duplicates mean the API is buggy." Duplicates under offset pagination on a changing dataset are the *defined* behaviour of offsets.
- "Virtualisation fixes long lists." It fixes render cost. It does not fix duplicates, restoration, find-in-page or announcement, and it makes three of those harder.
- "The back button will restore the list." Only if the pagination state is in the URL and the pages are still cached. Otherwise back means "start again from the top".
Measuring it, and what changes in the field
- Watch DOM node count in the Performance panel while scrolling a long list. A monotonically rising line with no plateau is the memory profile of an unvirtualised infinite scroll (Debugging Memory).
- Compare the time to append page one against page twenty. If they differ, node count is the cost, not the request (Interaction Responsiveness).
- In the field, look at how deep users actually go. Most lists are read shallowly, and a virtualisation project justified by page 200 may be optimising a page nobody reaches (Real User Monitoring).
- Server-side, page-depth distribution and the latency of deep offsets tell you when the API strategy has to change (Pagination That Survives a Large Table in Backend).
- On a slow device, node count is the constraint. A list that is fine at 200 rows on a laptop is janky at 200 rows on a mid-range phone (The Real Cost of JavaScript).
- On a slow network, page size is a latency decision: fewer, larger pages mean fewer round trips and longer stalls; more, smaller pages mean smoother appends and more requests (Bandwidth vs Latency in Networking).
- On a rapidly changing dataset, offset drift is severe and cursors are close to mandatory. On a mostly static one, offset is fine and much simpler.
- On a small screen, infinite scroll is conventional and pagination controls are awkward; on a desktop with a keyboard, explicit pages are often faster to operate.
- Cursors cost you page numbers, total counts, and jump-to-page — permanently. That is not an implementation gap, it is what "no addressable position" means.
- Offsets cost you correctness under insertion. Every duplicate row your users report is the price, and it cannot be fixed on the client.
- Virtualisation caps render cost and breaks find-in-page, screen-reader list navigation and naive scroll restoration. It is a real trade, not an upgrade (List Virtualization).
- Caching pages for instant back navigation costs memory that grows with how far the user walked, and stale pages that must be revalidated when they return (Stale-While-Revalidate).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe structural properties — offset is addressable and unstable, cursor is stable and unaddressable — follow from what each one identifies, so they hold for REST, GraphQL connections and any RPC scheme alike (GraphQL: Client-Shaped Queries Over One Schema in API Design uses cursors for exactly this reason).
- DEVICE-SPECIFICThe point at which an unvirtualised list becomes janky depends on the device: a desktop browser handles several thousand simple rows comfortably, while a mid-range phone with complex rows can struggle in the low hundreds. Any fixed row-count threshold you write down is wrong for one of the two.
- PLATFORM-SPECIFICScroll restoration behaviour differs across browsers and across native app shells: browsers restore scroll on history navigation by default and frameworks routinely disable it, while an embedded web view may not restore at all. Test the back button on the platforms you actually ship to.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a list read across two requests is a read of two different snapshots. Cursor pagination narrows the window in which that matters; it does not close it.