A user saw 20 records on page one. Page two showed some of the same records. Nobody changed the query.
ORDER BY created_at DESC
LIMIT 20 OFFSET 20
This worked perfectly while the table had few writes. At scale, new rows landed constantly.

Between the request for page one and the request for page two, three rows were inserted at the top. Everything shifted by three. Rows 18, 19, and 20 moved onto page two and appeared twice.
If 20 new rows arrive before someone fetches page two, they could see almost the same records as page one.
The pagination worked correctly. It simply paginated a slightly different table every time.
There was another problem: OFFSET 50000 doesn’t skip 50,000 rows for free. The database walks them and throws them away before returning the next 20. Later pages became slower and slower, and we blamed the index.
We moved to cursor pagination:
- The client sends back the last row it saw.
- The query starts strictly after that row.
- The ID acts as a tie-breaker because
created_atis not unique.
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20
Offset counts from the top on every request, so anything inserted above your page moves the window. A cursor names the exact row where you stopped, so the next page begins there no matter what was inserted above it.
Offset asks the database to count from the beginning. A cursor remembers the last row you already saw.