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.

Restful ApiApi DesignOpenapi

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:

http
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:

http
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:

json
{
  "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:

json
{
  "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.

json
{
  "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:

json
{
  "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:

json
{
  "query": {
    "status": "active",
    "region": "eu"
  },
  "items": []
}

Offset-Based Pagination

Offset-based pagination uses an integer position together with a page size.

For example:

http
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:

json
{
  "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:

text
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:

sql
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:

text
Page 1
A
B
C

Before requesting the next page, a new item is inserted at the beginning:

text
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:

http
GET /resources?cursor=eyJpZCI6IjUwMCJ9&limit=100

A response may look like:

json
{
  "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:

text
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:

sql
SELECT *
FROM resources
WHERE id > :last_id
ORDER BY id
LIMIT 100;

Instead of:

sql
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:

text
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:

http
GET /resources?limit=100&sort=createdAt

However, sorting only by a non-unique field may still produce unstable page boundaries.

Consider:

text
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:

text
ORDER BY created_at, id

A cursor can then logically represent:

text
(createdAt, id)

instead of only:

text
createdAt

This significantly reduces the risk of duplicate or missing resources during traversal.


Filtering and Pagination

Pagination must operate consistently with filtering.

For example:

http
GET /resources?status=active&region=eu&limit=100

If a cursor is returned for this query, it must continue the same logical result set.

The following is incorrect:

http
GET /resources?status=active&cursor=<cursor>

if the original cursor was generated using:

http
GET /resources?status=active&region=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:

http
GET /resources

may behave as:

http
GET /resources?limit=100

The API may allow clients to request a different size:

http
GET /resources?limit=50

but should enforce an upper bound.

For example:

text
Default limit: 100
Maximum limit: 1000

This prevents clients from effectively bypassing pagination with requests such as:

http
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:

json
{
  "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:

sql
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:

json
{
  "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:

json
{
  "items": []
}

Or explicitly return:

json
{
  "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:

json
{
  "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:

json
{
  "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

  1. APIs exposing potentially large collections MUST support pagination.
  2. Successful paginated collection requests MUST return 200 OK.
  3. APIs SHOULD provide pagination navigation links such as self, next, prev, first, and last when applicable.
  4. Returned resources should normally be contained in an items array.
  5. totalCount may be provided when an exact collection size is useful and reasonably inexpensive to calculate.
  6. APIs should define a default and maximum page size.
  7. Cursor values MUST be opaque to API clients.
  8. Clients must not inspect, modify, or construct cursors.
  9. Paginated queries should use a deterministic and stable ordering.
  10. Offset pagination is generally suitable for stable datasets and page-number navigation.
  11. Cursor pagination is generally preferable for large, dynamic, distributed, or continuously changing datasets.
  12. Pagination behavior should remain consistent with filtering and sorting parameters.
  13. API pagination and UI pagination are related but separate concerns; the API should not assume a specific user-interface pagination model.

References

Pagination | Remi Resume