Reliable collection endpoints are more than a query with a LIMIT clause. They are public contracts that must remain predictable while records are inserted, updated, deleted, and accessed concurrently. Poor pagination can cause clients to miss records, process duplicates, overload a database, or depend on implementation details that later become impossible to change.
This guide explains API pagination best practices for cursor-based collection endpoints. It covers deterministic ordering, filtering, page limits, continuation tokens, consistency behavior, security, testing, and the trade-offs between cursor and offset pagination.
Why pagination is an API design concern
An API collection represents a business resource, not necessarily a database table. Resource-oriented design keeps public names and semantics separate from internal storage, allowing the implementation to evolve without forcing client changes. For example:
GET /v1/customers/42/orders?status=paid&limit=25&cursor=opaque-tokenThis endpoint exposes orders belonging to a customer and supports filtering and pagination without revealing whether the data comes from SQL, a search index, or several services. The collection contract should define what records are visible, how they are ordered, how many are returned, and how a client obtains the next page. Guidance such as resource-oriented API design is useful when establishing those boundaries.
Pagination should be part of the initial design. Adding it later can break clients that assumed an unpaginated response contained every result, especially if they deserialize the response into a complete in-memory collection.
Cursor versus offset pagination
Offset pagination
Offset pagination uses a position and page size:

GET /v1/orders?offset=100&limit=25It is familiar, easy to implement, and useful when users need direct navigation to a numbered page. It can also be convenient for small, mostly static datasets and administrative interfaces.
Its weakness is that the offset describes a position in a changing result set, not a specific record. Suppose page one contains records 1–25. Before the client requests page two, a new record is inserted at the beginning. The original record 25 may shift to position 26, causing it to appear again or causing another record to be skipped. Deletions create the inverse problem.
Large offsets can also become expensive. Depending on the database and query plan, the server may scan and discard many preceding rows before returning the requested page.
Cursor pagination
Cursor pagination uses a continuation token that represents where the previous page ended:
GET /v1/orders?limit=25&cursor=eyJvcmRlcl9hdCI6IjIwMjUtMDEtMT..."The server uses the cursor and the endpoint’s ordering rules to fetch records after that position. With a suitable index and stable ordering, this is generally more efficient and more predictable for large or frequently changing datasets.
Cursor pagination does require more design work. Tokens must be opaque, validated, scoped correctly, and treated as temporary continuation state rather than as a public representation of database columns.
| Concern | Offset | Cursor |
|---|---|---|
| Implementation simplicity | Simple | More involved |
| Changing datasets | May skip or duplicate records | More stable when ordered correctly |
| Jumping to a page | Convenient | Not usually supported |
| Large collections | May degrade at high offsets | Can use indexed seek conditions |
| Token management | Minimal | Requires encoding, validation, and expiration decisions |
Choose based on the collection’s behavior. Cursor pagination is usually the safer default for feeds, event histories, transaction lists, and other large collections that change while clients are reading them. Offset pagination remains reasonable when random page access is a real requirement and consistency demands are modest.
Design a deterministic ordering
Cursor pagination cannot be reliable without a total, deterministic order. Ordering by a non-unique field such as created_at alone is insufficient because multiple records may share the same timestamp. The database can return tied rows in different orders across requests.

Add a stable unique tie-breaker. A common ordering is:
ORDER BY created_at DESC, id DESCThe cursor must contain both values from the final item in the page. The next query can then seek to records after that exact position:
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size_plus_oneFor ascending order, the comparison changes accordingly. If row-value comparisons are unavailable, express the same logic explicitly:
WHERE created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)Do not silently change the default ordering after clients have shipped. Ordering is part of the compatibility contract. If clients use pagination to synchronize or process every item, a change in sort semantics can produce missed or repeated records even when the response schema remains unchanged.
Choose fields that remain stable
Cursor fields should not change while an item is being traversed. A creation timestamp plus immutable identifier is often suitable. Ordering by a mutable field such as priority or updated_at can cause records to move between pages during traversal.
If mutable ordering is required, document the behavior clearly. A client may see an item twice, miss it, or observe reordering as updates occur unless the server provides snapshot semantics or another consistency mechanism.
Define filtering and ordering together
Filters must be applied consistently to every page. The cursor identifies a position within the combination of filter and ordering—not merely a position in the entire collection.
For example:
GET /v1/orders?status=paid&customer_id=42&limit=25&cursor=...The token should be rejected or treated as invalid if it is reused with a different filter, ordering, tenant, or authorization scope. Otherwise a cursor generated for one result set could produce confusing results in another.
A robust cursor commonly includes or is cryptographically bound to:
- The last item’s ordering values.
- The collection or endpoint identity.
- Filter values and sort direction.
- The API or cursor format version.
- The tenant, principal, or authorization scope when relevant.
- An expiration time, if tokens are intentionally short-lived.
These values may be serialized and authenticated inside an opaque token. Do not assume that base64 encoding makes data secret. Avoid putting sensitive values in a token, and never treat a cursor as an authorization credential. The server must perform authorization checks for every request.
Use bounded limits and a clear response shape
Every collection endpoint should define a documented default page size and a maximum page size. The client may request a smaller limit, but the server should cap excessive values:
GET /v1/orders?limit=50Validate the parameter consistently. Decide whether zero, negative numbers, malformed values, and values above the maximum produce a validation error or are normalized. Returning an arbitrary result for invalid input makes client behavior difficult to reason about.
A simple response shape is:
{
"items": [
{ "id": "ord_100", "status": "paid" },
{ "id": "ord_099", "status": "paid" }
],
"next_cursor": "opaque-token"
}Return a null or omitted continuation token when no page remains. Some APIs also return a has_more boolean, but it should not contradict the token. Avoid promising an exact total count unless it is important and affordable to calculate; counts can be expensive or become stale during traversal.
Fetch one extra record
A common implementation requests one more record than the client asked for. If the client requests 25 items, the server fetches 26:
- Return at most 25 items.
- If a 26th item exists, create a cursor from item 25.
- Otherwise, return no continuation token.
This avoids a separate count query and gives the server a reliable way to determine whether another page exists.
Choose and document consistency behavior
Cursor pagination does not automatically create a snapshot. On a live collection, inserts, updates, and deletes may occur between requests. The API should state what clients can expect.

Live traversal
Each request reads the current collection using the cursor’s position. This is scalable and usually appropriate for operational lists. New records inserted before the current position may not appear in the current traversal, while records whose ordering fields change may move. Clients should tolerate such behavior if the API documents it.
Snapshot traversal
The first request establishes a read snapshot, and subsequent cursors refer to that snapshot. This provides stronger repeatability but may require database snapshot support, a materialized result set, or a server-side snapshot identifier. Snapshots consume resources and may need expiration limits.
Eventually consistent collections
If the collection is backed by an asynchronous index or replicated store, newly written records may not appear immediately. State this explicitly. A successful write does not necessarily mean a subsequent list request will show the new record. Consistency guarantees after mutations are part of the API contract, not an implementation footnote.
Validate cursors and return useful errors
Malformed, expired, incompatible, or unauthorized cursors should produce a clear client error rather than a server exception or an empty page that looks valid. Use a consistent machine-readable format such as Problem Details for HTTP APIs.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-cursor",
"title": "Invalid cursor",
"status": 400,
"detail": "The cursor is expired or does not match this collection query.",
"instance": "/requests/abc123"
}Do not expose signing keys, SQL fragments, internal stack traces, or raw database errors. Include a correlation or request identifier so support teams can investigate failures without leaking operational details to the caller.
Decide whether a cursor mismatch is a client validation error, commonly 400 Bad Request, or an authorization failure. Do not use cursor validation as a substitute for object-level authorization. The caller must still be authorized to access the collection and each returned resource.
Secure the collection endpoint
Pagination can amplify security and capacity problems. A caller may request huge pages, expensive filters, deeply nested expansions, or repeated traversals. Apply controls such as:
- Maximum page sizes and response payload limits.
- Allowlisted filter and sort fields.
- Query timeouts and database statement limits.
- Per-user, tenant, and service-wide rate limits.
- Authorization checks before applying tenant or object filters.
- Careful redaction of sensitive fields in collection responses.
Rate limiting should provide actionable signaling, commonly with 429 Too Many Requests and an appropriate Retry-After header. Broader API threats—including broken object-level authorization and unrestricted resource consumption—are covered in the OWASP API Security Top 10.
Do not bind incoming query parameters directly to an ORM’s order or filter expression. Map public fields to explicitly allowed internal fields. This prevents clients from accessing undocumented columns or injecting unsafe query fragments.
Index for the access pattern
The database should support the filter and order used by the endpoint. For a query filtered by tenant and status, then ordered by creation time and ID, an index may need to reflect that shape:
WHERE tenant_id = :tenant
AND status = :status
AND (created_at, id) < (:created_at, :id)
ORDER BY created_at DESC, id DESCThe exact index depends on the database, cardinality, and query planner, so verify it with realistic data and execution plans. Cursor pagination reduces the need to skip earlier rows, but it does not make an unindexed filter efficient.
Common mistakes and better alternatives
Ordering by a timestamp alone
Problem: Ties make page boundaries ambiguous.
Better: Add a unique, stable tie-breaker and include it in both the ordering and cursor.
Returning database IDs as the cursor
Problem: It exposes implementation details and encourages clients to depend on internal ordering.
Better: Return an opaque, authenticated token with a versioned format.
Ignoring query changes between pages
Problem: A cursor generated for status=paid is reused with status=cancelled, producing invalid or surprising results.
Better: Bind the cursor to filters, ordering, collection, and authorization scope.
Using a mutable sort field without documenting it
Problem: Updates move records across page boundaries.
Better: Prefer immutable ordering fields, provide snapshot semantics, or document live traversal behavior.
Returning unbounded collections
Problem: Large responses cause high latency, memory pressure, and timeouts.
Better: Add a default and maximum limit from the beginning.
Assuming cursor pagination prevents duplicates completely
Problem: Live data, retries, replicas, and mutable records can still produce repeated observations.
Better: Document consistency guarantees and make consumers idempotent when processing records. If exactly-once processing matters, provide a dedicated synchronization or eventing design rather than relying solely on a list endpoint.
Document the contract with OpenAPI and tests
Describe pagination parameters, defaults, maximums, ordering, token errors, and consistency behavior in the API documentation. OpenAPI can support documentation, validation, client generation, and contract testing, although a schema alone cannot express every runtime rule.
Include examples showing the first request and continuation request:
GET /v1/orders?status=paid&limit=25
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [ ... ],
"next_cursor": "opaque-token"
}
GET /v1/orders?status=paid&limit=25&cursor=opaque-tokenTest more than the happy path. Useful cases include:
- Duplicate ordering values and identical timestamps.
- Inserts, updates, and deletes between page requests.
- Empty collections and a final page.
- Minimum, default, maximum, negative, and malformed limits.
- Expired, tampered, truncated, and cross-tenant cursors.
- Changing filters or sort parameters while reusing a cursor.
- Unauthorized resources that share identifiers with authorized ones.
- Database timeouts, replica lag, and rate-limit responses.
Use contract tests to ensure ordering and error behavior remain stable across releases. Monitor page latency, database query time, invalid-cursor rates, page-size distributions, throttling, and incomplete client traversals. These signals often reveal problems before users report missing or duplicated data.
Practical design checklist
- Is pagination present from the first version of the collection endpoint?
- Are default and maximum page sizes documented and enforced?
- Is ordering deterministic, indexed, and based on stable fields?
- Does the cursor encode or bind the collection, filters, ordering, and scope?
- Is the continuation token opaque and protected against tampering?
- Are malformed and expired cursors returned as consistent machine-readable errors?
- Does the API explain live, snapshot, or eventual consistency behavior?
- Are authorization and tenant filters applied on every request?
- Are expensive filters, expansions, and response sizes controlled?
- Are pagination behavior, examples, and compatibility rules published in OpenAPI documentation?
- Are inserts, deletes, ties, retries, and token reuse covered by tests?
Conclusion
Cursor pagination is not simply an alternative query parameter. It is a contract covering ordering, filtering, limits, continuation state, consistency, authorization, and evolution. The strongest implementations use a deterministic order with a stable tie-breaker, bounded requests, opaque tokens, explicit error behavior, and documented expectations for changing data.
Offset pagination can remain useful for small or mostly static collections and interfaces that require page jumping. For large, changing datasets, cursor pagination usually provides a more reliable foundation—provided the surrounding API semantics are designed with the same care.
Author: Neeraj Mourya
Founder, Systems Architect – Digitobit
Specializing in scalable backend architecture and performance-focused SaaS systems
