Why we paginate with cursors, not page numbers
May 20, 2026
?page=3 is the pagination everyone reaches for first. It is also the
pagination that quietly breaks in two different ways once a catalog gets big
and busy. This platform paginates products, blog posts, and API responses with
cursors instead — and here is the reasoning.
Problem one: offset gets slower the deeper you go
LIMIT 20 OFFSET 4000 does not jump to row 4000. The database produces all
4,020 matching rows in order, then throws the first 4,000 away. Page 200 is
genuinely more expensive than page 2, and the cost climbs the further a
shopper scrolls. On an infinite-scroll catalog — where scrolling deep is the
normal behaviour — that is exactly backwards.
Problem two: offset shows duplicates
Offsets assume the list holds still. It doesn’t. A shopper loads page 1. While
they read it, a new product is inserted near the top. They scroll to page 2 —
OFFSET 20 — and the row that was #20 has been pushed to position #21. They
see it again. Insert deletions and the mirror bug appears: items skipped
entirely. The shopper isn’t paging through a list; they’re paging through a
list that is moving under them.
Keyset pagination fixes both
A cursor doesn’t say “skip 4,000 rows.” It says “give me the rows after this specific one.” Each page carries an opaque cursor encoding the sort key of its last row:
GET /products?after=eyJpZCI6MTQ4Mn0
The next query is a plain indexed range scan:
SELECT * FROM products
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Two things fall out of this. The query cost no longer depends on scroll depth — page 200 costs the same as page 2, because it’s the same index range scan from a different starting key. And inserts above the cursor are simply irrelevant: “after this row” stays well-defined no matter what happens higher up the list. No duplicates, no skips.
The trade-offs, honestly
Cursors aren’t free. You give up “jump to page 47” — keyset pagination is
inherently next/previous, which is fine for infinite scroll and feeds but wrong
for, say, a paginated admin table where operators want page numbers. The sort
key has to be unique, which usually means appending a tiebreaker like id. And
the cursor is exposed to the client, so in this platform CursorPaginator
encodes it opaquely and integrity-checks it — a shopper should not be able to
edit a cursor to escape the scope they were given.
When to use which
Use offset pagination for bounded, stable, jump-to-page lists — most admin screens. Use cursor pagination for anything large, live, and scrolled: catalogs, feeds, public API list endpoints. This platform’s shopper-facing lists are all the second kind, so they all use cursors — and they stay fast at any depth, on a catalog that never stops changing.