API Pagination Tokens: Opaque, Stable, and Forgiving
Most pagination bugs come from clients constructing or modifying tokens in ways that look reasonable but break when the underlying data changes. Tokens that the server can verify and reject cleanly are the difference between a robust API and a fragile one.
Pagination is one of those API surfaces that looks trivial — return the first page, take a token, ask for the next page — and turns out to involve every hard problem in distributed systems: ordering, consistency, performance, security, schema evolution. Most of the bugs come from clients that construct or modify pagination tokens in ways that look reasonable but break when the underlying data changes. The tokens themselves are the central object: how they're constructed, what they contain, how the server verifies them, and how the API behaves when they become stale or invalid.
The patterns in this post apply to any paginated API. We use opaque tokens in DocuMint invoice listings, CronPing monitor lists, FlagBit flag enumeration, and WebhookVault request history.
The two pagination styles
Offset-based pagination uses a numeric offset and limit: the server skips offset rows and returns the next limit rows. This is what most early APIs ship with because it maps directly to SQL OFFSET LIMIT. The failure modes are well-known: offset performance degrades linearly with offset value because the database walks past every skipped row, and the result set is unstable because rows inserted at the start of the order shift everything's position by one. Page 5 might show the same row that was on page 4 last time, or skip one. For human-browsed result sets where the user is going to give up after a few pages and the data is mostly stable, offset pagination is fine. For programmatic clients that page through the entire result set or that revisit pages, it's wrong.
Cursor-based pagination uses a token that encodes the position in the order. The server reads the token, finds the position, and returns the next page from that position. Performance is constant rather than linear in offset — the database seeks to the position and reads forward without walking past skipped rows. Stability is also improved because the cursor names a specific row rather than a position that shifts as the data changes. The cost is that the cursor encoding is more involved than a simple offset.
The contents of a cursor
A correct cursor encodes everything the server needs to resume the iteration. For an order-by-created_at query, the cursor needs the created_at value of the last row returned plus the row's primary key for tiebreaking. The next page query is "WHERE (created_at, id) > (cursor.created_at, cursor.id) ORDER BY created_at, id LIMIT N". The tuple comparison handles the case of ties cleanly.
For more complex orderings, the cursor needs a tuple that matches the ordering columns. An order by status, then created_at, then id, requires a three-element cursor. The cursor format is conceptually similar to the SQL composite index that supports the query — the cursor is the same set of columns the index is built on.
The cursor should also encode any filter parameters that affect the iteration. If the original request was filtered to a specific customer or status, the cursor should encode that filter. This prevents a client from changing the filter mid-iteration and getting incoherent results. Either the server rejects a cursor that doesn't match the new filter, or the cursor is interpreted as having locked in the original filter — both are defensible, and the choice should be documented.
Opaque vs structured tokens
The cursor can be exposed as a structured value (a JSON object with named fields) or as an opaque string (a base64-encoded blob the client should treat as a magic value). Opaque is almost always the right choice. A structured cursor invites the client to construct, parse, or modify it, which couples the client to the server's pagination implementation and makes future changes painful. An opaque cursor lets the server change its internal cursor format at will — adding fields, changing encoding, switching from a tuple to a more complex representation — without breaking clients.
The standard pattern is a JSON object that the server constructs, then base64url-encodes. The client receives the encoded string and includes it verbatim in the next request. The server decodes, validates, and uses the contents. The client never inspects, modifies, or constructs the token.
For additional safety, the server should sign the token with an HMAC. The signature prevents clients from forging cursors or modifying them in transit. It also lets the server reject tokens that have been tampered with, which is a much cleaner error than processing an invalid cursor and returning incoherent results. The signing key can be a shared secret per environment or a per-API-key value — either works.
Cursor expiration and staleness
Cursors should have a TTL. The cursor encodes the position in the iteration, but the underlying data is changing — rows are being inserted, deleted, or updated. A cursor that's been sitting in a client's database for a week may point to a position that no longer makes sense, or that returns results different from what would be returned if the iteration started fresh.
The TTL gives the server permission to reject old cursors with a clear error. The TTL should be encoded in the cursor itself (along with the issued-at timestamp) and verified on each use. A typical TTL is 1 hour for active iterations and 24 hours as a hard maximum. Clients that need longer-lived iteration positions should re-fetch from the start with appropriate filters rather than holding a stale cursor.
The staleness behavior is also worth thinking about. If a row that was on a previous page is deleted before the client requests the next page, what happens? The next page should probably skip the deleted row rather than fail, because the iteration semantics are "give me what's still there in order, not in any kind of snapshot". This is the most common cursor semantics, and the most useful for the typical paginated-listing use case.
The has_next signal
A pagination response should tell the client whether there's more data. The standard trick is to query for limit+1 rows and use the (limit+1)th row's existence as the signal. If exactly limit rows come back, the iteration is done; if limit+1 come back, the iteration continues, the first limit rows are returned, and the (limit+1)th row's cursor becomes the next page's starting cursor.
The reason this is better than counting the total result set is that counting requires walking the entire result set, which defeats the performance benefit of cursor pagination. The has_next-via-extra-row trick gives you the same information at constant cost.
Returning an explicit has_next field in the response body is more readable than relying on the absence of a next_cursor field, but both work. We use both: the response body has has_next: true|false and a next_cursor field that's populated only when has_next is true.
Total counts
Don't return total counts in paginated responses by default. Computing the total requires a separate query that scans the entire filtered result set, which can be many times more expensive than the page itself. For most clients, the total isn't useful — they want to iterate through results, not display "page 47 of 1024". Make total count an opt-in parameter that the client requests explicitly, and document the cost.
For UIs that genuinely need total counts (a dashboard with "showing 1-25 of 1043 results"), the total can often be cached or estimated. Postgres has pg_stat_user_tables.n_live_tup for table-wide estimates, and many filter combinations can be pre-aggregated. The cached or estimated total is usually close enough for UI purposes and avoids the expensive count query.
The five test cases
Five test cases catch most pagination bugs: (1) iterating through the full result set returns every row exactly once, (2) iterating through a result set whose data is changing during iteration handles inserts and deletes gracefully, (3) sending an expired or tampered cursor returns a clear 4xx error rather than incoherent results, (4) iterating through a result set with many tied ordering values uses the tiebreaker column correctly, (5) the empty result case returns an empty page with has_next: false. Implement these five tests as integration tests against a real database and they'll catch most of the pagination bugs that ship to production.
The deeper observation
Pagination tokens are a small surface that exposes most of the hard problems in API design: schema evolution (the cursor format will change and clients shouldn't break), consistency (the cursor names a position that may move), security (clients shouldn't forge cursors), performance (counting is expensive). Treating tokens as opaque, signed, expiring values closes off entire classes of bugs by removing the client's ability to do things wrong. The work of designing the token correctly once pays back over the lifetime of the API in bugs that don't happen and customer integrations that don't break when the implementation changes. Most of the time we don't think of pagination as a place where careful engineering matters; the experience of having shipped APIs with both casual and careful pagination is that it does.