HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 92 min read

Offset vs Cursor Pagination

System designsystem design / API design

Two ways to page through a big list. At page 1 they look identical. At page 50,000 one of them quietly falls over. Knowing which — and why — is a classic intermediate line.

Offset pagination

GET /items?limit=20&offset=40000

SQL: ... ORDER BY created_at DESC LIMIT 20 OFFSET 40000

The catch: OFFSET doesn't skip rows for free. The DB still scans and discards all 40,000 rows before handing you 20. Page 1 is instant; deep pages get linearly slower. On a 10M-row table, "jump to page 9000" can take seconds and hammer your DB.

Worse — it's unstable. If someone inserts or deletes a row while the user is paging, every later row shifts by one. Result: the user sees a duplicate item, or skips one entirely, without any error.

Cursor (keyset) pagination

Instead of a numeric offset, you pass a pointer to the LAST row you saw:

GET /items?limit=20&after=2026-06-20T10:00:00Z_1843

SQL: ... WHERE (created_at, id) < ('2026-06-20T10:00:00Z', 1843)

ORDER BY created_at DESC, id DESC LIMIT 20

The index seeks straight to that position. No scan-and-throw-away → page 50,000 is as fast as page 1. And because the cursor anchors to a real row's key (not a count), inserts/deletes elsewhere don't shift your window. Stable + fast.

Side by side

  • Deep-page speed: Offset = slow (O(offset)). Cursor = constant.
  • Stability under writes: Offset = drifts. Cursor = stable.
  • "Jump to page 500": Offset = easy. Cursor = can't (only next/prev).
  • Total count / page numbers: Offset = natural. Cursor = awkward.
  • Implementation: Offset = trivial. Cursor = needs care.

The gotcha everyone hits

Cursor sorting MUST be on a unique, immutable key — or a composite that ends in one. Sort by created_at alone and two rows share a timestamp? Your < comparison straddles them and you silently drop or repeat rows at the page boundary. Fix: always tie-break with a unique column, e.g. ORDER BY created_at DESC, id DESC, and put both in the cursor. That's why the cursor above carries BOTH the timestamp and the id.

Rule of thumb: user-facing infinite scroll / feeds / APIs at scale → cursor. Small admin tables where humans click "page 7" → offset is fine.

Exercise

You have ORDER BY created_at DESC with no tie-breaker, and 5 rows share the exact same created_at right at a page boundary. Walk through what the user sees on page 2. Then rewrite the WHERE clause and the cursor token to fix it. Bonus: how would you encode the cursor so clients can't tamper with it (hint: base64 + opaque)?