Quick answer
Use offset pagination for small, simple admin-style lists. Use cursor pagination for public APIs, feeds, and changing datasets where clients need stable next-page navigation. Use keyset pagination when backend performance and stable ordering matter at scale.
Pagination is not just a frontend convenience. It is part of the API contract, the database query shape, and the user experience.
Why pagination matters
Returning every record from an endpoint creates slow responses, large payloads, expensive database queries, and unpredictable client behavior. A paginated API gives clients a bounded response and gives the backend a predictable workload.
The hard part is choosing the pagination model. A small internal table can use a simpler strategy than a high-traffic feed, audit log, or search endpoint.
Strategy comparison
| Strategy | Best for | Main tradeoff |
|---|---|---|
| Offset pagination | Small lists, admin tables, simple UIs | Large offsets can become slow and unstable as data changes. |
| Cursor pagination | Feeds, timelines, public APIs, changing data | Cursors require a stable sort order and careful token design. |
| Keyset pagination | Large ordered datasets and performance-sensitive endpoints | It is less flexible for jumping to arbitrary page numbers. |
No strategy is universally best. The right choice depends on access pattern, data size, sorting needs, and whether records are changing while users paginate.
Offset pagination
Offset pagination uses limit and offset, or page and pageSize.
GET /api/articles?limit=20&offset=40
This means: skip the first 40 records and return the next 20.
Offset pagination is easy to understand, easy to implement, and easy to connect to page-number UIs. It is often good enough for admin screens and low-volume datasets.
The downside is performance and consistency. Large offsets can force the database to scan and discard many rows before returning the page. If new records are inserted while the client paginates, items may be skipped or repeated.
Cursor pagination
Cursor pagination returns an opaque token for the next page.
GET /api/articles?limit=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA0LTA1In0
The client should not need to understand the cursor contents. It should treat the cursor as a token provided by the server.
A typical response looks like this:
{
"items": [
{
"id": "article_123",
"title": "REST API Status Codes Explained"
}
],
"pageInfo": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA0LTA1IiwiaWQiOiJhcnRpY2xlXzEyMyJ9",
"hasNextPage": true
}
}
Cursor pagination works well for feeds and public APIs because the server can encode the last seen position and return the next slice. It avoids telling the database to skip a large number of rows.
Keyset pagination
Keyset pagination uses the last item from the previous page as the boundary for the next query.
GET /api/articles?limit=20&createdBefore=2026-04-05T12:00:00Z&idBefore=article_123
The database query can then use an indexed comparison:
WHERE (created_at, id) < (:createdBefore, :idBefore)
ORDER BY created_at DESC, id DESC
LIMIT 20
This is efficient because the database can continue from a known point in the ordered index. It is a strong fit for large datasets, logs, timelines, and event streams.
The tradeoff is that clients cannot easily jump to page 37. Keyset pagination is designed for next-page navigation, not arbitrary page numbers.
Stable sorting is required
Pagination needs a deterministic order. Sorting only by a non-unique field can produce unstable pages.
For example, this is incomplete:
ORDER BY created_at DESC
If several rows have the same created_at, their relative order can change between requests. Add a unique tie-breaker:
ORDER BY created_at DESC, id DESC
The cursor or keyset boundary should include both values. This prevents missing or duplicated records when many rows share the same timestamp.
Response shape
A clean paginated REST response should make the next action obvious:
{
"items": [],
"pageInfo": {
"limit": 20,
"nextCursor": null,
"hasNextPage": false
}
}
Keep metadata separate from the item array. This makes the response easier for clients to parse and leaves room for future fields.
Should you return total counts?
Total counts are useful for admin tables and reports, but they can be expensive on large filtered datasets. Counting can also become misleading when data changes quickly.
For small lists, totalCount is fine:
{
"items": [],
"pageInfo": {
"page": 3,
"pageSize": 20,
"totalCount": 143
}
}
For large feeds, prefer hasNextPage and nextCursor. Avoid promising exact page counts unless the backend can calculate them cheaply and correctly.
Filtering and pagination
Filters must be part of the pagination contract. A cursor generated for one filter should not be reused with a different filter.
For example, a cursor for status=published should not be valid for status=draft. If the filter changes, the client should restart pagination from the first page.
For cursor tokens, include or validate the filter context. For keyset parameters, document which sort and filter fields are supported.
Common mistakes
The first mistake is using offset pagination for very large or frequently changing datasets. It may work in development and become slow in production.
The second mistake is sorting by a field that is not unique. Always add a stable tie-breaker such as id.
The third mistake is exposing cursor internals as a public contract. Use opaque cursors so the backend can change the implementation later.
The fourth mistake is accepting unlimited limit values. Put a reasonable maximum on page size and document it.
The fifth mistake is changing pagination behavior without versioning or migration notes. Pagination affects client loops, imports, exports, and integrations.
Practical recommendation
For a first API version:
- Use
limitwith a documented maximum. - Prefer cursor pagination for user-facing or public APIs.
- Use stable ordering with a unique tie-breaker.
- Return
itemsandpageInfo. - Avoid exact total counts unless there is a clear product need.
- Treat cursors as opaque tokens.
For small internal admin tools, offset pagination is still acceptable. The important part is choosing deliberately rather than using offset everywhere by default.
Related reading
Read REST vs RPC for API style tradeoffs, REST API Status Codes Explained for response design, and Idempotency in APIs Explained for retry-safe backend workflows. You can also browse the API Design topic cluster for related articles.