Offset Pagination Breaks When the Data Changes Between Pages

Key takeaway: Offset pagination’s simplicity is real and its correctness problem is equally real — it assumes the dataset is stable between page requests, and in a live system where records are inserted and deleted, that assumption fails in ways that are invisible to the client and hard to debug from the server side.
How Offset Pagination Fails
Offset pagination works by asking for records starting at a given position — ?offset=100&limit=50 — which assumes that position 100 in the dataset is stable between requests. If a record is inserted before that position between page one and page two, every record shifts by one, and the client receives a duplicate of a record it already saw on the previous page. If a record is deleted, the client instead skips a record it never saw.
Neither failure produces an error — the client simply receives data that is subtly wrong, with no indication that a record was duplicated or skipped, which makes this class of bug particularly hard to notice and to diagnose, since the data looks plausible and the client has no way to know it is missing or repeating something.
| Pagination type | Stable data | Live data with inserts/deletes |
|---|---|---|
| Offset | Correct | Skips or duplicates records silently |
| Cursor | Correct | Correct, stable across changes |
| Keyset | Correct | Correct, stable across changes |
Why Cursor Pagination Avoids This
Cursor pagination returns an opaque token — a cursor — that encodes the position of the last record returned, and the next page request passes that cursor back, so the server resumes from exactly that record rather than from a numeric position that can shift. Because the cursor references a specific record rather than a position in a shifting list, inserts and deletes elsewhere in the dataset do not affect the correctness of subsequent pages.
The cursor is typically derived from a stable, sortable field — often the primary key or a timestamp combined with the key — which lets the server efficiently fetch records after that point using an indexed comparison rather than a potentially expensive offset scan, which is also frequently a performance improvement over offset pagination on large datasets, since offset pagination must scan and discard all the records before the offset on every request.
When Offset Pagination Is Genuinely Fine
Offset pagination is a reasonable choice for small, effectively static datasets — a list that rarely changes, or a dataset small enough that the entire result fits comfortably in memory and the pagination is essentially cosmetic. It is also simpler to implement and to reason about, and for these cases its simplicity is a genuine advantage rather than a compromise.
The problem is that many systems adopt offset pagination for datasets that are neither small nor static, because it is the default and the simplest thing to reach for, and the correctness issue only surfaces once the dataset is large and actively changing enough for the skips and duplicates to actually occur and matter.
The Practical Recommendation
For any dataset that is large enough to need pagination at all and is actively changing, cursor pagination is the more correct choice, and the additional implementation complexity is modest — the main requirement is a stable, sortable field to base the cursor on, which most tables already have in their primary key. The trade-off is that cursors are opaque and less human-readable than offsets, which is a minor cost compared to silently incorrect data.
The Bottom Line
Use cursor pagination for any dataset that is large enough to paginate and is actively changing, since offset pagination silently skips or duplicates records when data changes between page requests. Reserve offset pagination for small, effectively static datasets where its simplicity is a genuine advantage and its correctness limitation cannot actually manifest.



