Pagination: Cursor vs. Offset
You'll learn to
- -Implement offset-based and cursor-based pagination, and explain the correctness problem offset pagination has under concurrent writes
- -Design a pagination response envelope that tells the client whether more pages exist
No collection endpoint should ever return "everything" unbounded - pagination caps response size and gives clients a predictable way to walk through a large result set. The two dominant approaches, offset and cursor, look similar at first glance and behave very differently once real, concurrent writes enter the picture.
Offset Pagination: Simple, With a Real Correctness Bug
GET /orders?limit=20&offset=0 # first 20 orders
GET /orders?limit=20&offset=20 # next 20 ordersOffset pagination is trivial to implement and lets a client jump directly to "page 5," but it has a genuine correctness problem under concurrent writes: if a row is inserted before the current offset while a client is paging through, every subsequent page shifts by one, causing the client to either skip a row it should have seen or see a row twice. For a slowly-changing admin table this rarely matters in practice; for a live feed with constant writes, it is a real, user-visible bug.
Cursor Pagination: Stable Under Concurrent Writes
GET /orders?limit=20
HTTP/1.1 200 OK
{
"data": [ /* 20 orders */ ],
"next_cursor": "eyJpZCI6MTIwfQ==",
"has_more": true
}
# the client passes the returned cursor to get the next page:
GET /orders?limit=20&cursor=eyJpZCI6MTIwfQ==A cursor encodes a stable position (typically the last-seen item's sort key, like an ID or timestamp, often base64-encoded to keep it opaque to the client) rather than a raw row count. Because the next page is defined relative to "after this specific item" instead of "skip N rows," a new insertion anywhere else in the table doesn't shift what the next page returns - the trade-off is that a client can no longer jump straight to "page 5" without walking through the pages before it.
The Response Envelope
Whichever approach is used, the response should tell the client explicitly whether more data exists (`has_more`, or a `next_cursor` that is simply absent/null on the last page) rather than making the client infer it from getting back fewer than `limit` items - that inference breaks the moment the result count happens to land exactly on a page boundary.
Never expose raw internal database identifiers as an opaque-looking cursor without actually opacifying them (e.g. base64-encoding a JSON blob) - a "cursor" a client can trivially decode and manipulate isn't really opaque, and clients will inevitably start depending on its internal structure once they notice it.
You're designing pagination for a social media feed that receives new posts constantly. The interviewer asks why you'd avoid offset pagination here specifically.
"Offset pagination is generally worse, so I'd always avoid it."
"For a feed with constant writes, offset pagination has a real correctness bug: if new posts are inserted while a user scrolls, every post shifts position in the underlying order, so the client can end up seeing the same post twice or skipping one entirely between page loads. Cursor pagination avoids this because each page is defined relative to 'after this specific post,' which stays correct regardless of what gets inserted elsewhere. I wouldn't rule out offset pagination everywhere, though - for a rarely-changing admin table where jump-to-page matters more than write-concurrency correctness, it's still a reasonable choice."
What correctness problem does offset pagination have under concurrent writes?
Design Page Turner in the API Design Lab's REST Foundations act.