Why does offset pagination break, and what should you use instead?
Because OFFSET tells the database to fetch and discard rows — so it gets slower the deeper you go — and because the underlying data changes between requests, which makes results skip and duplicate items in ways users notice and nobody tests for.
The two separate problems:
Performance. LIMIT 20 OFFSET 100000 requires the database to locate and step over 100,000 rows before returning anything. Cost grows linearly with offset, so page 1 is instant and page 5,000 times out. Indexes do not fix this, because the rows must still be counted.
Correctness. Between fetching page 1 and page 2, rows are inserted or deleted. If a row is inserted before your position, everything shifts down by one and an item you already saw appears again; if a row is deleted, an item is skipped entirely and never shown. On an actively changing dataset this is not an edge case — it is the normal outcome.
Cursor (keyset) pagination, which fixes both. Instead of "skip 100,000", you ask for "rows after this specific position": WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20.
Why this is fast: the index seeks directly to the position. Performance is constant regardless of depth.
Why this is correct: the cursor identifies a row, not a count, so insertions and deletions elsewhere do not shift it.
The requirements it imposes:
A stable, unique sort key. Sorting by a non-unique column needs a tiebreaker — usually the primary key — or rows at a boundary are skipped or repeated.
The sort order must match the cursor comparison, and compound cursors need row-value comparison rather than chained AND/OR, which is a common bug.
No jumping to an arbitrary page, which is the genuine trade-off — cursors give next and previous, not "page 47".
Opaque encoded cursors are preferable to exposing raw column values.
Where offset is still fine: small, static datasets, admin tables, and anywhere a total page count is genuinely required.