PAUL CHONGSenior Software Engineer

Pagination: Offset vs Cursor

2026-08-05 · 8 min read

Pagination is how a client fetches a large dataset in manageable chunks. The two dominant strategies are offset pagination and cursor pagination. They look similar on the surface but have meaningfully different properties under concurrent writes — which is exactly when it matters most.

Offset Pagination

Fetch a page by telling the server how many rows to skip.

GET /posts?limit=10&offset=20

The server translates this directly to SQL:

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;

Page 1 is offset=0, page 2 is offset=10, page 3 is offset=20, and so on. The client tracks the current page number and computes the offset itself.

The problem: page drift

Offset pagination is unstable under concurrent writes. If a new post is inserted at the top of the feed between page 1 and page 2 fetches, every row shifts down by one. Page 2 will re-serve the last item from page 1. If a post is deleted, you skip an item entirely. For a slow-moving dataset like a product catalog, this is acceptable. For a live feed, it is not.

The deep pagination problem

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;

Even though you only return 10 rows, the database still scans and discards 10,000 rows to find the right starting point. Offset queries get slower as users page deeper. A LIMIT 10 OFFSET 100000 on an unindexed table is a full sequential scan.

When to use offset

  • The dataset is relatively static (product catalogs, admin tables, search results that are re-ranked on each query)
  • You need to jump to an arbitrary page ("go to page 47")
  • Simplicity matters more than stability (internal tools, low-traffic endpoints)

When not to use offset

  • The feed has concurrent inserts or deletes (social feeds, notifications, activity logs)
  • You need infinite scroll — drift causes duplicate or missing items which is visible to users
  • The dataset is large and users might page deep

Cursor Pagination

Fetch the next page by passing back a pointer to the last item you saw.

GET /posts?count=10&cursor=eyJpZCI6IDQyfQ==&direction=older

The cursor encodes the position of the last item returned. direction tells the server which way to page from that position. The server uses it to fetch the next page:

SELECT * FROM posts
WHERE id < :cursor_id
ORDER BY id DESC
LIMIT 10;

This is a range query, not a skip. The database uses the index on id to jump directly to the right position — no scanning discarded rows. It stays fast at any depth.

Cursor encoding

Cursors are typically opaque to the client: base64-encoded JSON that the client stores and returns verbatim. This hides implementation details and lets the server change the cursor format without breaking clients.

// Server encodes
const cursor = Buffer.from(JSON.stringify({ id: lastPost.id })).toString('base64');

// Server decodes on next request
const { id } = JSON.parse(Buffer.from(cursor, 'base64').toString());

Composite cursors

When sorting by a non-unique field like created_at, ties can produce inconsistent results. The cursor needs both fields to be stable:

SELECT * FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;

The client never sees this — it just stores the opaque cursor string.

What the response looks like

{
  "data": [...],
  "pageInfo": {
    "startCursor": "eyJpZCI6IDUwfQ==",
    "endCursor": "eyJpZCI6IDQyfQ==",
    "hasNextPage": true,
    "hasPreviousPage": false
  }
}

endCursor is the cursor for the next older page. startCursor is the cursor for checking newer content. This is the shape Relay, GitHub's GraphQL API, and most modern APIs use.

Bidirectional navigation

A cursor identifies a position; direction tells the server which way to go from it.

GET /posts?count=10&cursor=eyJpZCI6IDQyfQ==&direction=older   // infinite scroll (next page)
GET /posts?count=10&cursor=eyJpZCI6IDUwfQ==&direction=newer   // stale-feed check (new posts)
  • direction=older: fetch posts older than the cursor — the standard infinite scroll case
  • direction=newer: fetch posts newer than the cursor — used to detect new content without disturbing scroll position

The stale-feed check is what powers the "N new posts — tap to refresh" banner. On mount, store startCursor (the newest item in the first page). Poll or use a WebSocket event to trigger a direction=newer fetch against that cursor. If results come back non-empty, show the banner.

Ranked feeds

Cursor pagination requires a stable sort key. Timestamps and IDs are stable — a post's created_at never changes. Ranking scores are not — a post's score shifts as engagement accumulates or freshness decays. If the ranking re-runs between page fetches, a cursor pointing to "the item at score 0.87" is meaningless.

The standard solution is feed sessionization: when the user opens the feed, the server runs the ranking algorithm once and stores the ordered list of post IDs in a cache (Redis, Memcached) keyed by session. The cursor then points to a position in that frozen snapshot.

Session start  →  rank posts  →  store [id, id, id, ...] in Redis (sessionId key)
GET /feed?sessionId=abc&cursor=10&count=10  →  return snapshot[10..20]

New high-ranking posts that arrive mid-session are held in a background queue. When enough accumulate, show the "N new posts" banner. Pull-to-refresh runs the ranker again, writes a new snapshot, and resets the cursor to position 0. The active session is never mutated — the cursor stays valid throughout. Posts that rank low simply don't make the cut — most ranking algorithms include a freshness signal that decays over time, so genuinely new posts compete on score without special-casing.

For time-ordered feeds (reverse chronological), sessionization is unnecessary. created_at is immutable, so the sort key is already stable and cursor pagination works directly against the live table.

The exception is mid-feed injection — ads, promoted posts, or breaking news that must appear in the current scroll session. These bypass the snapshot entirely: the server merges injected items into the next batch at fetch time, and the client filters incoming IDs against a local set of already-rendered post IDs to suppress accidental duplicates.

When to use cursor

  • Live feeds with concurrent inserts/deletes (social feeds, notifications, chat history)
  • Infinite scroll — cursors are stable, no duplicate or missing items
  • Large datasets where deep pagination performance matters
  • Any list where consistency across page boundaries is important

When not to use cursor

  • You need random access ("jump to page 47") — cursors are strictly sequential
  • The dataset is small and static — offset is simpler and the tradeoffs don't matter
  • Users need to share a deep-link to a specific page by number

Comparison

OffsetCursor
Query mechanismLIMIT n OFFSET kWHERE id < :cursor LIMIT n
Stable under writesNo — drift on insert/deleteYes
Deep pagination perfDegrades (full scan)Constant (index seek)
Random accessYesNo
Client complexityLow (track page number)Medium (store and return cursor)
URL shareable pageYesNo (cursor is ephemeral)
Best forStatic datasets, admin UIsFeeds, infinite scroll, live data

Frontend Implementation

Offset: the client tracks page as a number, computes offset = page * limit, and passes it as a query param. Straightforward but you must handle the case where items shift between fetches.

Cursor with infinite scroll: on mount, fetch the first page with no cursor. Store endCursor in state. When the user scrolls near the bottom, fetch the next page using the stored cursor, then append results to the list. Repeat until hasNextPage is false.

const [posts, setPosts] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);

async function loadMore() {
  const res = await fetchPosts({ limit: 10, cursor });
  setPosts(prev => [...prev, ...res.data]);
  setCursor(res.pageInfo.endCursor);
  setHasMore(res.pageInfo.hasNextPage);
}

TanStack Query's useInfiniteQuery wraps this pattern: it stores each page's cursor automatically and exposes fetchNextPage and hasNextPage so you don't manage cursor state manually.

Dynamic page size

The first fetch should fill the viewport, but the server doesn't know the viewport height. In CSR, the client knows window.innerHeight before the first request — use it to compute count:

const ITEM_HEIGHT = 120; // estimated height per post card
const count = Math.ceil(window.innerHeight / ITEM_HEIGHT) + 2; // +2 buffer row
fetchPosts({ count, cursor: null, direction: 'older' });

In SSR, the server renders before any browser layout is available. Overfetch slightly — a fixed count of 20–25 fills any reasonable viewport — and let the client hide overflow if needed.

Conclusion

Cursor pagination is almost always the right answer for feeds because new entries would shift the offsets, causing duplicates on scroll.

The cases where you'd choose offset are when your data is relatively static and the client needs the ability to jump to an arbitrary page by number, since cursors are strictly sequential.