Offset counts places from the top. A cursor names an item.
Two items land on top. “Skip 4” now starts two places early, so 18 and 17 repeat. “After 17” resumes exactly.
Can clients just dedupe?
Bad: “Drift only repeats items”
Drop repeats by ID.
Good: A delete skips one, silently
Delete an item you’ve seen, and the next unseen one falls between pages. No client can tell.
4,760×
more rows read by OFFSET at page 5,000
At 20 per page, page 5,000 reads 100,000 rows to return 20: skipped rows are still computed. A keyset seek reads 21.
Three details make a cursor correct
- End the sort with a unique ID as tiebreaker
- Fetch 21 rows, return 20: a 21st means more
- Index the columns in the same order as the sort
Base64 alone is not obfuscation
If users can deconstruct a token, they will. Sign it, version it, bind it to the query and expire it, so you can change it later.
Cursors can’t say “page 3 of 15”. Offset can.
No jumping: page 500 means walking 499 pages, and totals need a separate count. An admin table of 300 coupons keeps offset; feeds get cursors.
Cursors aren’t snapshots. A ranked feed re-scores every request, so page 2 reshuffles everything.
The fix
Rank once, store the top 500 IDs for about 30 minutes, and page by index through that frozen list.
Remember
Every list takes a limit, default 20, max 100. Feeds get an opaque, signed cursor holding the last item’s sort key and ID: flat cost, no drift. Small admin tables keep page numbers.