Both return fifty rows. Only one of them stays fast, and only one is correct
while the table is being written to.
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 499950;
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ('2026-03-11 09:14:22', 88213)
ORDER BY created_at DESC, id DESC
LIMIT 50;
The keyset version needs an index on (created_at DESC, id DESC) and the row
comparison, which most engines support directly. Its cost is constant: page one
and page ten thousand read the same fifty rows.
The correctness argument is the stronger one and gets mentioned less. Under
OFFSET, a row inserted while a user pages shifts everything down by one, so they
see a row twice; a deletion shifts up and they miss one entirely. Keyset anchors
on a value from the last row seen, so concurrent writes cannot shift the window —
new rows simply appear where they belong.
The id tiebreaker is not decoration. Without it, two posts sharing a timestamp
make the sort order non-deterministic, so a row can be skipped or repeated at
exactly the page boundary — a bug that appears in production and never in
testing, because test data rarely has ties.
What you give up is jumping to an arbitrary page number, since you need the
previous page's last row to compute the next. For infinite scroll and API
cursors that is not a loss; for a numbered-page admin table it is, which is the
one place OFFSET remains reasonable — bounded to a few pages.