API Design · Pagination · REST · Performance · Best Practices
Cursor Pagination vs Offset Pagination: When Each REST API Pattern Makes Sense
A practical comparison of cursor-based, offset-based, and keyset pagination for REST APIs—trade-offs, implementation guidance, and when to choose each approach for growing datasets.
Published:
The Short Answer
Offset pagination is fine for small, static datasets where users need to jump to arbitrary pages. Cursor pagination is the right default for growing or frequently updated collections because it avoids the performance degradation and data-drift problems that offset-based approaches introduce at scale. Keyset pagination is a middle ground that gives you stable navigation without the cursor encoding complexity.
Why Pagination Choice Matters
When your API serves thousands or millions of records, returning everything in one response is not an option. Pagination breaks results into manageable chunks, but the method you choose directly affects query performance, client complexity, and data consistency as your dataset grows.
The three patterns you will encounter in REST API design are offset-based pagination, cursor-based pagination, and keyset pagination. Each has distinct trade-offs.
Offset-Based Pagination
Offset pagination uses two parameters: an offset (the starting row number) and a limit (how many rows to return). This maps directly to the SQL LIMIT and OFFSET clauses that most developers already know.
How it works:
GET /api/products?offset=0&limit=10
GET /api/products?offset=10&limit=10
GET /api/products?offset=20&limit=10
Where it works well:
- Small datasets (hundreds to low thousands of rows)
- Static or rarely-changing data
- Admin interfaces where jumping to page 50 is a real requirement
- Simple implementations where developer time is the bottleneck
Where it breaks down:
The fundamental problem is that the database must scan and discard every row before the offset. If you request offset 50,000 with a limit of 10, the database reads 50,010 rows and discards the first 50,000. As the offset grows, query time grows proportionally. This is not a theoretical concern—it is a direct consequence of how B-tree and heap scans work in relational databases.
A second problem is data drift. If rows are inserted or deleted between paginated requests, the user sees duplicate records or gaps. Contentful documented this exact issue when they migrated their content APIs away from offset pagination: content added or removed between page requests caused results to shift unpredictably.
Cursor-Based Pagination
Cursor pagination replaces the numeric offset with a cursor value—typically a opaque token that encodes the position in the result set. The client passes the cursor from the previous response to fetch the next page.
How it works:
GET /api/products?limit=10
→ response includes next_cursor: "eyJpZCI6MTIzNH0="
GET /api/products?limit=10&cursor=eyJpZCI6MTIzNH0=
→ response includes next_cursor: "eyJpZCI6MTIzNX0="
Where it works well:
- Large or growing datasets
- Frequently updated or real-time data
- Infinite scroll interfaces
- APIs where consistent ordering is more important than random access
How to implement it correctly:
The cursor should encode the values used in the ORDER BY clause, not just a row ID. A cursor based only on an auto-incrementing ID fails when rows share the same sort key or when the sort column is not unique. A robust implementation encodes the ordering columns plus the ID as a tiebreaker:
SELECT * FROM products
WHERE (created_at, id) > ('2024-01-15T10:30:00Z', 1234)
ORDER BY created_at ASC, id ASC
LIMIT 10;
The cursor token encodes created_at and id from the last row. This ensures deterministic ordering even when multiple rows share the same timestamp.
The trade-off:
Cursor pagination does not support random access. You cannot jump to page 50. You must walk the cursor sequentially. This is a deliberate design choice, not a limitation to work around. If your use case requires page numbers, offset pagination or keyset pagination is more appropriate.
Including a total count:
A common client request is to show “Page 3 of 47” with cursor pagination. This requires a separate COUNT(*) query, which adds latency. The fastapi-pagination library addresses this by allowing a custom cursor page type that includes the total, but it comes at the cost of an additional database query on every response. Decide whether the UX benefit justifies the performance cost for your specific API.
Keyset Pagination
Keyset pagination is closely related to cursor pagination but uses explicit column values instead of an opaque token. The client passes the last seen value of the sort column directly.
How it works:
GET /api/products?limit=10&after_id=1234
GET /api/products?limit=10&after_id=1235
Where it works well:
- When you want cursor-like performance without opaque tokens
- When the sort column is unique or you can add a tiebreaker column
- When debugging and manual API exploration matter
The trade-off:
Keyset pagination exposes your data model to the client. The sort column becomes part of the public API contract. If you need to change the sort order or column names, you break existing clients. Cursor pagination hides this detail behind an encoded token, making schema changes safer.
Comparison Summary
| Concern | Offset | Cursor | Keyset |
|---|---|---|---|
| Random page access | Yes | No | No |
| Performance at deep offsets | Degrades | Stable | Stable |
| Data drift resistance | Poor | Strong | Strong |
| Implementation complexity | Low | Medium | Low |
| Schema change resilience | N/A | Good | Poor |
| Total count support | Native | Requires extra query | Requires extra query |
When to Choose Each Pattern
Choose offset pagination when:
- Your dataset is under 10,000 rows and grows slowly
- Your API consumers need to jump to specific pages
- You are building an admin tool or report where page numbers are expected
- The simplicity of
LIMIT/OFFSEToutweighs the performance cost
Choose cursor pagination when:
- Your dataset exceeds 10,000 rows or grows continuously
- Data is updated frequently and consistency across pages matters
- You are building a feed, timeline, or infinite scroll experience
- You want to avoid data drift without exposing sort columns to clients
Choose keyset pagination when:
- You want stable, performant pagination without opaque tokens
- Your sort column is stable and unlikely to change
- Debugging and manual API testing are important to your workflow
- You are willing to bake the sort column into your public contract
Implementation Checklist
Regardless of which pattern you choose, these practices apply:
-
Always sort explicitly. Never rely on implicit database ordering. An
ORDER BYclause without an index will scan the entire table. -
Index your sort columns. Offset pagination at depth requires an index on the offset column. Cursor and keyset pagination require an index on the sort key plus any tiebreaker columns.
-
Return navigation metadata. Include
next_cursoror equivalent in every response. GitHub’s REST API usesLinkheaders withrel="next"andrel="prev"for this purpose. Cursor-based APIs should include bothnextandpreviouscursors when applicable. -
Document the cursor format. Whether opaque or explicit, clients need to know how to extract and pass the cursor value. Document whether the cursor is URL-safe, base64-encoded, or a raw value.
-
Set a maximum page size. Allow clients to request a limit, but enforce an upper bound. This prevents a single request from overwhelming your database or your client’s memory.
-
Test with concurrent writes. If your data changes while a client paginates, verify that the results are consistent. Cursor and keyset approaches handle this better than offset, but you should still validate the behavior for your specific workload.
FAQ
Can I combine offset and cursor pagination in the same API?
Yes. Some APIs offer offset pagination for simple use cases and cursor pagination for large datasets. The GitHub REST API uses offset-based pagination with page numbers. If you offer both, document which endpoints use which pattern and make the distinction clear in your OpenAPI specification.
Does cursor pagination work with filtering?
Yes, but the cursor must encode the filter conditions as well as the sort key. A cursor that only encodes the sort position will produce incorrect results when filters change the result set. Include filter parameters in the cursor token or require clients to pass the same filters with every paginated request.
What about GraphQL pagination?
GraphQL’s Relay connection spec uses cursor-based pagination by design. The cursor in GraphQL typically encodes the node ID and position, making it compatible with the cursor approach described here. If you are building a GraphQL API, follow the Relay spec rather than inventing a custom pagination model.
Is keyset pagination the same as cursor pagination?
They solve the same problem with different client-facing contracts. Keyset pagination exposes the sort value; cursor pagination hides it behind a token. From a database performance perspective, they are equivalent when implemented correctly.
Sources
- https://www.merge.dev/blog/cursor-pagination
- https://www.contentful.com/blog/cursor-based-pagination
- https://medium.com/@maryam-bit/offset-vs-cursor-based-pagination-choosing-the-best-approach-2e93702a118b
- https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
- https://github.com/uriyyo/fastapi-pagination/discussions/960
