2026-08-24 17:14:31.177144+08 / restful-api
Pagination
Guidelines for offset and cursor pagination, stable ordering, filtering consistency, page sizes, and navigation links.
Pagination
Pagination should be used when an API exposes collections that may contain more than a few hundred items. It protects the service from excessive resource consumption and provides clients with a predictable and efficient way to iterate through large datasets.
MUST Support Pagination for Large Collections
APIs that expose potentially large collections MUST support pagination.
Pagination helps:
- Prevent excessive memory, database, and network usage.
- Reduce response latency and payload size.
- Protect services from overload.
- Improve client-side iteration and batch processing.
- Avoid returning an unbounded number of resources in a single request.
A paginated request should return 200 OK when processed successfully.
Two commonly used pagination strategies are:
- Offset-based pagination — a numeric offset identifies the position of the first item to return.
- Cursor-based pagination — an opaque cursor identifies a position within the result set.
The pagination mechanism is an API implementation decision and should be selected according to the characteristics of the underlying data and the client use cases.
Pagination Query Parameters
Pagination parameters should use consistent and conventional names.
For offset-based pagination:
GET /resources?offset=100&limit=50
Typical parameters are:
| Parameter | Description |
|---|---|
offset |
Number of items to skip before returning results |
limit |
Maximum number of items to return |
For cursor-based pagination:
GET /resources?cursor=eyJpZCI6IjEyMzQ1In0&limit=50
Typical parameters are:
| Parameter | Description |
|---|---|
cursor |
Opaque pointer identifying a position in the collection |
limit |
Maximum number of items to return |
Clients MUST NOT inspect, modify, or construct cursors themselves.
A cursor should be treated as an opaque value generated by the server.
SHOULD Provide Pagination Links
Paginated responses SHOULD provide navigation links whenever applicable.
Common link relations include:
self— current page.first— first page.prev— previous page.next— next page.last— last page.
Providing navigation links reduces the amount of pagination logic required on the client and allows the server to control how pages are addressed.
A typical paginated response may contain:
{
"totalCount": 1000,
"self": {
"href": "/resources?offset=500&limit=100"
},
"first": {
"href": "/resources?offset=0&limit=100"
},
"prev": {
"href": "/resources?offset=400&limit=100"
},
"next": {
"href": "/resources?offset=600&limit=100"
},
"last": {
"href": "/resources?offset=900&limit=100"
},
"query": {
"status": "active"
},
"items": []
}
The response structure is independent of whether the API internally uses offset-based or cursor-based pagination.
Response Structure
A paginated collection should normally place returned resources inside an items array.
Example:
{
"items": [
{
"id": "resource-501",
"name": "Resource 501"
},
{
"id": "resource-502",
"name": "Resource 502"
}
]
}
Additional pagination metadata may be included.
totalCount
totalCount represents the total number of resources matching the request.
{
"totalCount": 1000,
"items": []
}
It is optional because calculating an exact count may be expensive or impractical for some data stores.
isLast
An optional isLast property may indicate that the current response represents the final page.
For example:
{
"isLast": true,
"items": []
}
This can be useful when an API intentionally does not calculate totalCount.
query
A query object may contain filters that were applied to the collection.
For example:
{
"query": {
"status": "active",
"region": "eu"
},
"items": []
}
Offset-Based Pagination
Offset-based pagination uses an integer position together with a page size.
For example:
GET /resources?offset=500&limit=100
The server skips the first 500 matching resources and returns up to 100 resources.
A corresponding response could be:
{
"totalCount": 1000,
"self": {
"href": "/resources?offset=500&limit=100"
},
"first": {
"href": "/resources?offset=0&limit=100"
},
"prev": {
"href": "/resources?offset=400&limit=100"
},
"next": {
"href": "/resources?offset=600&limit=100"
},
"last": {
"href": "/resources?offset=900&limit=100"
},
"items": []
}
Advantages
Offset-based pagination is:
- Simple to understand.
- Easy to implement.
- Widely supported by databases and frameworks.
- Convenient when clients need page numbers.
- Suitable when users need to jump directly to an arbitrary page.
For example, a UI that provides:
Previous 1 2 3 ... 51 ... 100 Next
usually maps naturally to offset-based pagination.
Limitations
Offset pagination may become inefficient for very large datasets.
A database query such as:
SELECT *
FROM resources
ORDER BY created_at
LIMIT 100
OFFSET 500000;
may require the database to locate or process hundreds of thousands of records before returning the requested page.
Performance degradation is particularly relevant for:
- Very large datasets.
- Distributed databases.
- Sharded databases.
- NoSQL databases.
- Frequently changing datasets.
Offset pagination can also produce inconsistent results when the underlying collection changes between requests.
For example, suppose the client retrieves:
Page 1
A
B
C
Before requesting the next page, a new item is inserted at the beginning:
X
A
B
C
D
The offset has shifted. Depending on the implementation, the client may now receive duplicate items or skip items entirely.
Cursor-Based Pagination
Cursor-based pagination uses an opaque pointer representing a position in the result set.
Example:
GET /resources?cursor=eyJpZCI6IjUwMCJ9&limit=100
A response may look like:
{
"totalCount": 1000,
"self": {
"href": "/resources?cursor=<self-cursor>&limit=100"
},
"first": {
"href": "/resources?cursor=<first-cursor>&limit=100"
},
"prev": {
"href": "/resources?cursor=<previous-cursor>&limit=100"
},
"next": {
"href": "/resources?cursor=<next-cursor>&limit=100"
},
"last": {
"href": "/resources?cursor=<last-cursor>&limit=100"
},
"items": []
}
The cursor may internally represent information such as:
- The identifier of the last returned resource.
- Sort-key values.
- Pagination direction.
- Query filters.
- Snapshot or consistency information.
However, these implementation details MUST NOT be exposed as a client contract.
The client should only use cursors returned by the server.
Cursor Opacity
A cursor MUST be treated as an opaque value.
Clients must not:
- Parse it.
- Modify it.
- Generate it.
- Depend on its internal representation.
For example:
cursor=eyJpZCI6IjEwMDAiLCJkaXIiOiJuZXh0In0
Even if this happens to be Base64-encoded JSON, clients must not decode it and construct another cursor.
The server must remain free to change the cursor implementation without breaking API consumers.
Advantages
Cursor-based pagination works particularly well for:
- Large datasets.
- Continuously changing datasets.
- Event streams.
- Activity feeds.
- Distributed databases.
- Sharded databases.
- NoSQL databases.
- APIs where clients normally iterate sequentially.
It also avoids the increasingly expensive database offsets associated with deep offset-based pagination.
A database implementation may use keyset pagination internally:
SELECT *
FROM resources
WHERE id > :last_id
ORDER BY id
LIMIT 100;
Instead of:
SELECT *
FROM resources
ORDER BY id
LIMIT 100
OFFSET 500000;
The first query can generally make better use of an index.
Limitations
Cursor-based pagination is less suitable when clients need to jump directly to an arbitrary page.
For example:
Go directly to page 51 of 100
A cursor normally represents navigation relative to a known position. Without knowing the cursor for page 51, the client cannot directly construct that position.
Exact total counts may also be costly or unavailable in systems that naturally use cursor pagination.
Choosing Between Offset and Cursor Pagination
Neither approach is universally better.
The pagination mechanism should be selected according to API requirements.
| Requirement | Offset | Cursor |
|---|---|---|
| Simple implementation | ✅ | ◐ |
| Broad framework support | ✅ | ◐ |
| Jump to arbitrary page | ✅ | ❌ |
| Display page numbers | ✅ | ◐ |
| Large datasets | ◐ | ✅ |
| Frequently changing datasets | ◐ | ✅ |
| Distributed / sharded storage | ◐ | ✅ |
| Sequential iteration | ✅ | ✅ |
| Exact total count | ✅ | ◐ |
| Stable traversal during inserts/deletes | ◐ | ✅ |
As a general guideline:
Use offset-based pagination for relatively stable datasets where page-number navigation is useful.
Use cursor-based pagination for large or frequently changing datasets where efficient sequential traversal is more important than arbitrary page access.
Stable Ordering
Pagination requires a deterministic ordering.
A paginated collection should therefore define an explicit and stable sort order.
For example:
GET /resources?limit=100&sort=createdAt
However, sorting only by a non-unique field may still produce unstable page boundaries.
Consider:
createdAt
2026-08-20T10:00:00Z
2026-08-20T10:00:00Z
2026-08-20T10:00:00Z
If multiple resources have the same timestamp, their relative order may change between requests.
A stable implementation should use a unique secondary key, for example:
ORDER BY created_at, id
A cursor can then logically represent:
(createdAt, id)
instead of only:
createdAt
This significantly reduces the risk of duplicate or missing resources during traversal.
Filtering and Pagination
Pagination must operate consistently with filtering.
For example:
GET /resources?status=active®ion=eu&limit=100
If a cursor is returned for this query, it must continue the same logical result set.
The following is incorrect:
GET /resources?status=active&cursor=<cursor>
if the original cursor was generated using:
GET /resources?status=active®ion=eu
unless the server explicitly validates and reconstructs the original query state.
Cursor implementations may therefore encode or bind the cursor to:
- Filter values.
- Sorting configuration.
- Pagination direction.
- Page size where necessary.
A server should reject invalid or incompatible cursors rather than silently returning an unrelated result set.
Page Size
APIs should define a reasonable default page size and enforce a maximum page size.
For example:
GET /resources
may behave as:
GET /resources?limit=100
The API may allow clients to request a different size:
GET /resources?limit=50
but should enforce an upper bound.
For example:
Default limit: 100
Maximum limit: 1000
This prevents clients from effectively bypassing pagination with requests such as:
GET /resources?limit=100000000
The exact limits depend on resource size, query complexity, infrastructure capacity, and service-level requirements.
Total Count Considerations
Returning totalCount can be useful for clients:
{
"totalCount": 1285321,
"items": []
}
However, APIs should not assume that calculating the exact count is always inexpensive.
For large or distributed datasets, a query such as:
SELECT COUNT(*)
FROM resources
WHERE ...;
can be significantly more expensive than retrieving a single page.
Therefore, totalCount should be treated as optional unless the API contract or user experience specifically requires it.
Cursor-based APIs in particular may intentionally omit total counts.
For example:
{
"next": {
"href": "/resources?cursor=<next-cursor>"
},
"items": []
}
The presence or absence of next may be sufficient for sequential iteration.
Last Page Detection
There are several ways to indicate the end of a collection.
The API may omit next:
{
"items": []
}
Or explicitly return:
{
"isLast": true,
"items": []
}
The exact representation should remain consistent within the API.
Clients should preferably follow server-provided navigation information rather than calculating the next position themselves.
Recommended Design
A practical pagination response may use the following structure:
{
"totalCount": 1000,
"isLast": false,
"self": {
"href": "/resources?offset=100&limit=100"
},
"first": {
"href": "/resources?offset=0&limit=100"
},
"prev": {
"href": "/resources?offset=0&limit=100"
},
"next": {
"href": "/resources?offset=200&limit=100"
},
"last": {
"href": "/resources?offset=900&limit=100"
},
"query": {
"status": "active"
},
"items": [
{
"id": "resource-101"
},
{
"id": "resource-102"
}
]
}
Not every field is mandatory.
For many APIs, a smaller representation is sufficient:
{
"next": {
"href": "/resources?cursor=<next-cursor>"
},
"items": [
{
"id": "resource-101"
},
{
"id": "resource-102"
}
]
}
The design should avoid unnecessary metadata while still providing clients with enough information to navigate the collection reliably.
Pagination Design Rules Summary
- APIs exposing potentially large collections MUST support pagination.
- Successful paginated collection requests MUST return
200 OK. - APIs SHOULD provide pagination navigation links such as
self,next,prev,first, andlastwhen applicable. - Returned resources should normally be contained in an
itemsarray. totalCountmay be provided when an exact collection size is useful and reasonably inexpensive to calculate.- APIs should define a default and maximum page size.
- Cursor values MUST be opaque to API clients.
- Clients must not inspect, modify, or construct cursors.
- Paginated queries should use a deterministic and stable ordering.
- Offset pagination is generally suitable for stable datasets and page-number navigation.
- Cursor pagination is generally preferable for large, dynamic, distributed, or continuously changing datasets.
- Pagination behavior should remain consistent with filtering and sorting parameters.
- API pagination and UI pagination are related but separate concerns; the API should not assume a specific user-interface pagination model.