# 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: ```sql 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.