cursor-based pagination

How to Design Cursor-Based Pagination for SaaS APIs

Cursor-based pagination lets an API continue after a known position instead of skipping a numbered set of records. A reliable design needs a stable ordering, a validated continuation token, and clear rules for changes between requests.

For SaaS integrations, those details matter because customers may pull thousands of invoices or run scheduled syncs. We’ll use an invoice endpoint to show how the request, database boundary, and response fit together.

Key Takeaways

  • Sort by a stable field plus a unique tie-breaker, and preserve both values in the cursor.
  • Treat cursors as continuation instructions, never authorization, and bind them to the request’s tenant and filters.
  • Document live-data behavior because pagination alone doesn’t provide a frozen snapshot.

Define the Cursor-Based Pagination Contract

Choose request parameters and limits

Our example endpoint lists a tenant’s open invoices, newest first:

GET /v1/invoices?status=open&limit=2&sort=-created_at,-id

Here, limit controls page size, while the sort places newer timestamps first and breaks timestamp ties using descending numeric IDs. The first request omits cursor; later requests supply the token returned by the API.

For this design, use a default of 50 records and a maximum of 100. Reject noninteger or nonpositive limits, and cap larger values at 100. These are contract choices, not universal pagination rules.

Make continuation explicit

Return an items array, a nullable next_cursor, and a Boolean has_more. Keep those names and types consistent across collection endpoints so automation builders can reuse pagination logic.

Existing APIs use different conventions. Stripe’s pagination parameters, for example, include starting_after and ending_before, which accept object IDs.

An ID-based cursor works when the service can resolve it reliably. However, storing the full ordering boundary avoids depending on the continued existence of the last returned record.

Make the Sort Order Stable and Unique

A horizontal sequence of blue and teal database record tiles with geometric shapes and a cursor marker.

Add a unique tie-breaker

Sorting only by created_at leaves equal timestamps unordered. Two invoices created within the same timestamp precision could appear in either order.

Use (created_at DESC, id DESC) instead. The unique numeric ID makes every invoice’s position unambiguous within the tenant. PostgreSQL’s documentation calls for a unique ordering when LIMIT selects a subset.

Keep timestamp precision consistent between storage and cursor serialization. Rounding a database timestamp when generating the cursor can move the boundary and cause missing or repeated records.

Compare the complete boundary

Suppose the last returned invoice has timestamp 2026-10-20T10:00:00Z and ID 1041.

The next query selects invoices with an earlier timestamp, plus invoices with that same timestamp and an ID below 1041. Both comparisons are strict because the boundary invoice has already been returned.

Apply this as a keyset query, rather than retrieving rows and discarding everything before an offset. Performance still depends on suitable indexes and filters.

Apply tenant and visibility restrictions before limiting results. Otherwise, inaccessible records can distort page sizes or continuation signals.

Design Cursor Encoding and Validation

Separate opacity from encoding

Base64 converts bytes into text; it provides neither secrecy nor protection against modification. Anyone who receives a Base64-encoded JSON cursor can usually decode it.

An opaque cursor has no client-facing structure that consumers should interpret or construct. A random server-side reference is one option. Another is a stateless token containing a versioned payload protected by a signature.

Signed payloads remain readable unless encrypted. If the payload contains sensitive information, use authenticated encryption or keep that information server-side.

For our example, a random token references stored continuation state: the tenant, endpoint, filters, ordering boundary, version, and expiration. This avoids exposing invoice metadata inside the token.

Validate every request independently

First, authenticate the caller and resolve the tenant through trusted server-side context. Then validate the cursor’s format, existence, version, expiration, and association with the endpoint and tenant.

Compare its saved filter and sort settings with the current request. Reject changed settings with 400 invalid_cursor; clients must restart pagination to change the collection.

Google’s pagination guidance makes an important distinction: page tokens indicate where to continue, but they must never grant authorization.

Reapply permissions on every request, even when the cursor is valid. Also limit token length before decoding or looking it up, and never turn cursor fields into unvalidated database expressions.

Return the Next Page Without Guesswork

A browser window and a server stack exchange requests with two curved directional arrows.

Fetch limit + 1 matching, authorized records. With limit=2, retrieve up to three.

Return only the first two. If a third exists, set has_more to true and create the next cursor from the last returned record, not the extra record.

A sample response is:

{
  "items": [
    {
      "id": 1042,
      "created_at": "2026-10-20T10:00:00Z",
      "status": "open"
    },
    {
      "id": 1041,
      "created_at": "2026-10-20T10:00:00Z",
      "status": "open"
    }
  ],
  "next_cursor": "cur_8f3b6a914e2c4d5ba07f6918c2d4e5f0",
  "has_more": true
}

The next request preserves the filters and sorting:

GET /v1/invoices?status=open&limit=2&sort=-created_at,-id&cursor=cur_8f3b6a914e2c4d5ba07f6918c2d4e5f0

When no additional record exists, return next_cursor: null and has_more: false. An empty collection returns an empty items array with those same terminal values.

Clients should stop on that explicit terminal signal, not calculate completion from page length. Keep has_more and next_cursor consistent so consumers don’t need to resolve conflicting instructions.

Decide What Happens When Records Change

Explain live-data behavior

Cursor-based pagination avoids the positional shifts associated with offset pagination. For example, deleting an earlier record doesn’t change the saved ordering boundary.

Because our cursor stores the timestamp and ID, deleting invoice 1041 doesn’t prevent continuation. The next request can compare against those saved values without finding the deleted row.

However, later requests still read live data. A newer invoice inserted before the boundary won’t appear during that forward traversal. Meanwhile, an unseen open invoice that becomes paid no longer matches status=open.

A stable cursor preserves an ordering position. It doesn’t preserve collection membership or record values across requests.

Prefer immutable ordering fields. Sorting by updated_at allows records to move across the boundary, which can cause omissions or repeated records.

Choose stronger consistency deliberately

For browsing screens, live results are often acceptable if the behavior is documented.

Exports and reconciliation jobs may need stronger guarantees. A creation-time cutoff can exclude later-created records, but it doesn’t freeze edits, deletions, or backdated inserts.

If every exported value must reflect one point in time, use a database-supported snapshot strategy or a materialized export that preserves membership and values.

Keep incremental synchronization separate from historical traversal. A change feed with versioned events and deletion markers addresses a different problem than listing the current invoice collection.

Test Failure Cases and Observe Production Behavior

Test the full traversal against a fixed dataset first. Assert that every matching invoice appears once, in order, including records that share timestamps.

Then exercise changed data: delete the boundary record, insert newer records, update filters, and revoke permissions between requests. Include empty collections, exact page-size multiples, and a final page containing one record.

Security tests should cover malformed, expired, altered, and cross-tenant tokens. Return documented errors, such as 400 cursor_expired, without exposing another tenant’s state. Never silently restart from the first page after cursor validation fails.

For background jobs, save the next cursor only after successfully processing the current page. Use idempotent writes keyed by invoice ID so retries don’t duplicate downstream actions. Replaying a cursor may return different results against live data.

Finally, track page latency, cursor-validation failures, returned item counts, and stalled traversals. Avoid logging raw tokens or sensitive payloads; request IDs and error categories usually provide safer debugging context.

Frequently Asked Questions

Can clients jump directly to page 20?

Usually, they must follow continuation tokens until they reach that position. A cursor identifies an ordering boundary rather than a page number.

If random access matters, offer filters that narrow the collection or a separate numbered-pagination interface. Don’t invent page numbers from cursor tokens.

Should every response include a total count?

No. The limit + 1 technique establishes whether another page exists without calculating an exact total.

A total count may require additional database work, and live updates can make it differ between requests. Include one only when the interface or integration genuinely needs it, and define what it counts.

Conclusion: Implementation Checklist

A dependable pagination contract gives clients a clear continuation point while keeping permissions and data-change rules explicit. Start with deterministic ordering, then make the token and response behavior match that ordering.

Before shipping, complete this checklist:

  1. Define page-size defaults, caps, filters, and terminal response values.
  2. Order records using a stable field and unique tie-breaker.
  3. Preserve the complete boundary, and validate cursor integrity, scope, and lifetime.
  4. Fetch one extra authorized record and build the cursor from the last returned item.
  5. Document live-data behavior and provide a separate strategy for consistent exports.
  6. Test ties, deletions, permissions, retries, and invalid tokens; monitor failures in production.

About the author

The SAAS Podium

View all posts

Leave a Reply

Your email address will not be published. Required fields are marked *