API Pagination: Offset, Cursor, and Keyset Patterns
Returning a list of items from an API sounds simple until the list has ten thousand items. At that point, the response is too large to transfer efficiently, too slow to serialize, and too expensive to compute. Pagination is how APIs break large result sets into manageable chunks that clients can fetch incrementally. Choosing the wrong pagination pattern — or implementing the right one incorrectly — creates problems that do not appear until production load reveals them.
Offset Pagination: Simple and Familiar
Offset pagination is what most developers learn first. The client specifies a limit (how many items per page) and an offset (how many items to skip):
GET /articles?limit=20&offset=40
This returns items 41 through 60. Next page is offset=60. The pattern maps directly to SQL’s LIMIT and OFFSET clauses, which is why it feels natural to developers who think in database queries.
The implementation is genuinely simple, and for small or static datasets, it works fine. The problems emerge at scale and in dynamic data.
The database problem: OFFSET 10000 LIMIT 20 does not start reading at item 10,000. The database reads and discards 10,000 rows, then returns 20. The work scales linearly with the offset. Deep pagination in large tables is slow in ways that are not obvious from the API surface.
The consistency problem: if items are added to or deleted from the collection while a client is paginating, offset values shift. A new item inserted at the top of a reverse-chronological list pushes every subsequent item down by one position. A client paginating through with a fixed offset will either skip an item or see the same item twice depending on whether the insertion was before or after the current offset. For mostly-static data this is acceptable. For frequently changing data it produces silently incorrect results.
Cursor Pagination: Position, Not Offset
Cursor pagination replaces the numeric offset with a pointer to a specific position in the result set — typically the ID or creation timestamp of the last item on the current page. The next page request includes this cursor, and the server returns items after that point:
GET /articles?limit=20&cursor=eyJpZCI6IDEwNDJ9
The cursor is usually an opaque encoded value — base64 of a JSON object or an encrypted identifier — rather than a raw database ID. This hides the implementation detail and allows the server to change the cursor structure without breaking clients.
Cursor pagination solves both problems with offset pagination. Database queries become efficient: WHERE id > 1042 LIMIT 20 uses an index and does not scan anything it does not return. The position anchor is stable: insertions or deletions elsewhere in the collection do not affect what comes after the cursor position, so clients paginating through concurrent writes do not skip or duplicate items.
The tradeoffs: cursor pagination is sequential. You can go forward page by page, but you cannot jump directly to page 47. You also cannot easily tell a user “you are on page 3 of 50.” For interfaces that require random access or page number display, cursor pagination is a poor fit. For infinite scroll interfaces, background data synchronization, and API consumers processing large exports, it is the correct choice.
Keyset Pagination: The Database-Efficient Version
Keyset pagination is cursor pagination with an explicit focus on efficiency. Instead of an opaque cursor, the client uses actual field values from the last item as the position anchor:
GET /articles?limit=20&after_id=1042&after_created_at=2026-04-15T10:30:00Z
The server translates this to a compound predicate: records where (created_at, id) > ('2026-04-15T10:30:00Z', 1042) — a query that an index on (created_at, id) handles efficiently regardless of how many records precede the cursor.
Keyset pagination is the most performant pattern for large datasets because the query never touches rows outside the requested page. The cost is that the key fields driving pagination must be indexed, the client must pass back specific field values rather than an opaque token, and changing the sort order requires new key fields. It is the right choice when you need both efficient pagination and a predictable, stable ordering.
What the Response Should Look Like
Regardless of the pagination pattern, the response structure should give clients everything they need to fetch the next page without guessing:
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6IDEwNjJ9",
"has_more": true
}
}
For offset pagination, include total_count so clients can display “showing 41-60 of 342 results.” For cursor pagination, omit the total count (calculating it requires a full table scan that defeats the purpose) but include has_more so clients know whether to fetch another page.
The next_cursor being absent or null should signal the final page clearly. Clients that must poll for data.length < limit to detect the last page are working harder than they should.
Sorting and Filtering Interaction
Pagination interacts with sorting and filtering in ways that require attention. For cursor pagination, the sort field must be stable — if two records have identical sort values, the ordering between them must be deterministic or the cursor will not reliably anchor to the right position. Adding a tiebreaker (usually the ID) to any non-unique sort field solves this.
Filtering changes the effective dataset, which means a cursor from one filtered view is not valid in another filtered view. Either the cursor must encode the filter parameters, or clients must be warned not to change filters mid-pagination.
Choosing the Right Pattern
For datasets under a few thousand items with infrequent changes and interfaces that need random page access: offset pagination is acceptable and simpler to implement.
For large datasets, frequently updated data, infinite scroll interfaces, or bulk data export: cursor pagination is the correct choice and worth the additional implementation complexity.
For very large tables where maximum query performance matters and you control the key fields: keyset pagination.
Most modern public APIs default to cursor pagination for list endpoints. The developer experience is slightly more complex than offset pagination — you cannot jump to arbitrary pages — but the reliability and performance at scale make it the better contract for any dataset that might grow.