Designing API Cursor Pagination Tokens: starting_after vs cursor vs page_token

Three industry conventions for cursor pagination headers and parameters: starting_after (Stripe), cursor (Linear/GitHub-style), and page_token (Google-style). The differences are conceptual rather than mechanical and they shape what customers can do at the edges.

Cursor pagination is the right default for most B2B SaaS list endpoints, but the cursor itself has three competing conventions in the wild. The differences are small enough that the choice feels arbitrary at design time and matter enough that customers feel them at integration time.

The three conventions

The Stripe convention uses two parameters named starting_after and ending_before, each taking a resource ID. The customer reads a page, takes the ID of the last item, and passes it to starting_after on the next request. The convention is conceptually clean. The cursor is a resource ID, which means it is meaningful to humans and visible in logs, and the implicit contract is that the underlying list is ordered by ID and the API will return items strictly after the cursor.

The Linear and GitHub convention uses a single parameter named cursor (or after in GitHub's GraphQL surface) that takes an opaque string. The opaque string is server-generated and customers do not parse it. The convention preserves implementation flexibility: the server can change cursor encoding without breaking customers, and cursors can encode whatever the server needs (sort position, filter state, schema version) without leaking any of that into the public contract.

The Google convention uses page_token and next_page_token, where the response includes a next_page_token field and customers pass it back as a query parameter on the next request. The token is opaque and resembles the Linear convention, but the explicit pairing of token name in request and response makes the protocol slightly clearer. The convention is documented in the Google API Improvement Proposals and is the most prescriptive of the three.

What the choice actually changes

For the simple case of paging forward through a list, all three conventions produce identical behavior. The customer sends a request, receives a page plus a continuation token, and sends another request with the token. There is no difference in observable behavior or in the number of round trips.

The differences show up at the edges. Backward pagination is supported natively by the Stripe convention through the ending_before parameter, and supported in the Linear convention through has_previous_page plus before in the GraphQL connection pattern, and not supported at all in the Google convention. A team that ships only next_page_token has implicitly committed to forward-only pagination, which is fine for most use cases and surprising for the use cases where it is not.

Sort-order changes are another edge. With Stripe-style resource-ID cursors, switching from descending to ascending order requires the customer to know that the same cursor cannot be reused, because "after" in descending order is different from "after" in ascending order. With opaque cursors, the server can encode sort state in the cursor and the protocol does not change. The opacity is doing work at the cost of debuggability.

Filter changes are a third edge. With a resource-ID cursor, changing the filter mid-pagination is undefined because the cursor only carries position. With an opaque cursor, the server can encode filter state and either honor the original filter (ignoring the new one) or reject the request (because the cursor's filter does not match). With explicit page_token, the convention typically rejects.

Stability under data changes

All three conventions face the same problem: the underlying data changes between requests. New items are inserted, existing items are deleted, sort columns are updated. The cursor's job is to make some specific behavior correct in spite of this.

The most common useful guarantee is no-skip-no-duplicate, meaning that an item present at the start of pagination is returned at most once across all pages, even if items are inserted or deleted during pagination. The guarantee is met by cursors based on stable resource IDs and on sort columns that do not change after insert. It is not met by cursors based on offset (because insertions shift everything) or on mutable sort columns like updated_at (because updates move items between pages).

Snapshot pagination is the stronger guarantee where the entire pagination sees the data as it existed at the start. It requires the server to track a snapshot per cursor, either by IDs captured at start time or by an MVCC snapshot. The cost is server-side state. The Stripe API does not promise snapshot semantics. The Google convention typically does not either. Linear's GraphQL connection pattern can support it if the resolver is built that way but does not require it.

Cursor TTL

Opaque cursors implicitly raise the question of how long they remain valid. The conservative answer is one hour, after which the cursor is rejected with 410 Gone or equivalent. The aggressive answer is 24 hours or longer, which makes the cursor useful for resumable batch jobs at the cost of server-side state retention.

Resource-ID cursors do not have an explicit TTL because the cursor is just a record ID. They remain valid as long as the record exists. If the record is deleted, the cursor either points at "nothing" (in which case the next request returns items after that ID anyway) or is rejected, depending on implementation. Most implementations choose the more forgiving behavior.

What to pick

For a new B2B SaaS API, the choice depends on what you want to optimize. Resource-ID cursors (Stripe-style) are easiest to debug, easiest for customers to reason about, and limit implementation flexibility. Opaque cursors (Linear or Google style) preserve flexibility for sort-change and filter-change cases and limit what customers can do at the edges. Most teams should pick resource-ID cursors and accept the flexibility loss.

The reason to prefer opaque is anticipated complexity. If the list endpoint will eventually support multiple sort options, filter combinations that the cursor needs to encode, or snapshot semantics, opaque cursors do not require renaming or restructuring later. If the list endpoint will remain simple (one sort, fixed filters, no snapshots), opaque cursors are over-engineering.

What does not work

Mixing conventions across endpoints is the most common mistake. An API that uses starting_after on one endpoint and page_token on another and after on a third looks careless. The convention should be uniform across the API surface.

Reusing cursor names across unrelated meanings is the next most common mistake. Using "cursor" both as the pagination token and as the name of an internal database cursor reference is a documentation pitfall that produces customer confusion.

Exposing cursors that are obviously implementation details (raw base64-encoded JSON, sort-column values, hash-of-state) is a long-term commitment to that representation. The opacity is meant to allow the server to change. Once customers parse the format anyway, the flexibility is gone.

The deeper observation

Cursor pagination is one of the design choices where there is no single right answer, but there is a clear taxonomy of trade-offs. The three conventions have stabilized because each fits a different priority. Stripe optimizes for developer ergonomics and chose the human-readable cursor. Google optimizes for implementation flexibility and chose the opaque protocol. Linear sits in between with GraphQL conventions that allow either. The choice is mostly about what your API will look like five years from now.


Read more essays and technical writing at anethoth.com — a notebook on databases, distributed systems, biology, and the engineering that holds the world together.