Part V β The Craft Β· Lesson 10.11
Pagination Part-1
In one lineOffset is simple and drifts; cursor is stable and can't jump to page 7 β pick by what the data does.
Where you've seen itClicking "next" on a busy feed and seeing the same post again, because new items shifted everything down.
Diagram
OFFSET DRIFT β the bug that looks like a duplicate render
t=0 GET /posts?offset=0&limit=3
[ P9 ][ P8 ][ P7 ] user reads page 1
t=1 3 new posts arrive: P12, P11, P10
t=2 GET /posts?offset=3&limit=3
list is now:
[P12][P11][P10][P9][P8][P7][P6]β¦
0 1 2 3 4 5
βββββ΄ββββ΄ββ returns P9, P8, P7 AGAIN
Insertions shift every offset. Deletions skip items entirely.CURSOR β position defined by the data, not by a count
t=0 GET /posts?limit=3
[ P9 ][ P8 ][ P7 ] nextCursor: "P7"
t=1 3 new posts arrive (irrelevant β cursor points at P7)
t=2 GET /posts?limit=3&after=P7
[ P6 ][ P5 ][ P4 ] β no duplicates, no skips
The cursor is usually an opaque encoding of
(sortValue, tiebreakerId) β e.g. base64("2026-07-30T10:00Z|P7")CHOOSING
OFFSET CURSOR
jump to page 7 β β
total page count β usually β
stable under inserts β β
deep-page performance β O(n) scan β indexed seek
URL shareability β ?page=7 partial (opaque token)
implementation cost trivial moderate
admin tables, search results ββΊ OFFSET
feeds, chat, infinite scroll ββΊ CURSORHow it works
- Offset:
LIMIT 20 OFFSET 200. Simple, allows page numbers, and requires the database to walk 200 rows before returning any β slow at depth. - Cursor:
WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 20. Indexed seek, constant cost, immune to insertions. - Always include a tiebreaker. Sorting on a non-unique column alone (timestamps collide) drops or repeats rows at page boundaries.
- Keep the page state in the URL (6.10) β
?page=3or?after=<cursor>β so refresh and sharing work. - Prefetch the next page on hover or when the user nears the end (5.5), making the transition feel instant.
Trade-offs
| Use offset when | Use cursor when |
|---|---|
| Data is stable (reports, archives) | Items are inserted at the top |
| Users need page numbers | Infinite scroll (10.7) |
| The dataset is small | Deep pagination performance matters |
| Total count is a product requirement | Duplicates are unacceptable |
A middle path: cursor for the data, plus an approximate total ("about 12,000 results"), which is what most large search UIs actually do.
Interview angle
"Why did the same item appear on two pages?"
- Offset pagination on a list that receives inserts β every new row at the top shifts the window by one.
- Cursors fix it because position is defined by the row you last saw, not by a count.
- Keep a unique tiebreaker in the sort key, or identical timestamps will still cause skips at boundaries.
Recap
- Offset drifts under inserts and gets slower with depth; cursor is stable and constant-cost.
- Always sort by (value, unique id) so page boundaries are deterministic.
- Page state belongs in the URL; the next lesson builds the component.