Designing API Cursor TTLs: How Long Pagination Cursors Should Remain Valid

Pagination cursors are tokens that customers eventually misuse. Setting the right TTL is the difference between bounded server-side state and a cursor that holds open a database snapshot for a week.

Cursor-based pagination is now the default for most B2B APIs. The cursor encodes where the previous page ended and lets the server resume without re-scanning. What gets less discussion is how long the cursor should remain valid. A cursor that expires too aggressively annoys customers; a cursor that never expires creates server-side state that has to be managed forever. The answer is somewhere in between, and the specific number depends on how the cursor is implemented.

What the cursor encodes

Two patterns dominate. The first is a stateless cursor: the cursor is an opaque base64-signed encoding of the ordering keys of the last row returned. The server validates the signature and uses the encoded values to construct the next query. No server-side state is held; the cursor can in principle be valid forever, modulo schema changes.

The second is a stateful cursor: the cursor is an opaque ID that references server-side state (a captured row-ID list, an open transaction snapshot, or a continuation token in an external pagination service). The server has to hold the state for as long as the cursor is valid.

The TTL question is fundamentally different in these two cases. Stateless cursors can be made long-lived cheaply; stateful cursors cost storage and operational complexity proportional to how long they remain valid.

The stateless cursor TTL

For stateless cursors, the TTL is bounded by two concerns. The first is schema migration: if the cursor encodes column values and the schema changes, old cursors may no longer make sense. The second is customer expectation: a customer who saves a cursor and uses it three months later is probably doing something wrong, and returning fresh data instead of failing is rarely the right behavior.

The recommended approach is to include a schema version in the cursor envelope and reject cursors with old versions explicitly. For schema versions that have not changed, accept cursors indefinitely. For ones that have, return 410 Gone with a clear message about cursor expiry.

The customer-expectation concern is best handled by a soft TTL of 24-48 hours: cursors older than that are accepted but emit a deprecation header (Deprecation: true plus Sunset: timestamp). Customers who routinely use old cursors get visible warnings without breaking integrations.

The stateful cursor TTL

For stateful cursors, the TTL is the main mechanism for bounding storage cost. The state has to be retained until either the cursor is exhausted (all pages consumed) or the TTL expires. Most cursors are abandoned partway through (the customer queried the first page and never came back), so the TTL determines how much never-completed state accumulates.

The right TTL depends on the use case. For dashboard pagination where the customer is actively scrolling, 5-15 minutes is enough. For batch export where the customer is iterating systematically, 1-2 hours is enough. For API integrations that may pause for retries or human review, 6-24 hours is more appropriate. For long-running data sync where the customer wants a consistent snapshot across a long iteration, the TTL has to be longer than the expected iteration time, possibly 7 days.

The schema is straightforward: a cursors table keyed by ID with creation time, expiration time, and an opaque state blob. A periodic cleanup job removes expired cursors. The disk cost is low because each cursor is small (a few KB at most), but the count can grow large if TTLs are long and creation rate is high.

The expiration response

The most common bug in pagination TTL handling is silent expiration. The cursor is no longer valid, but the server returns fresh data starting from the beginning. The customer's integration loops, processing the first page repeatedly, never advancing, and either runs forever or hits some other limit before someone notices.

The right response to an expired cursor is 410 Gone with a structured error body that distinguishes cursor expiration from other 410 cases. The customer's integration can then explicitly handle the case (restart from the beginning, or fail explicitly).

The 410 status code is the right one because the cursor genuinely no longer exists, as opposed to 404 (which would suggest the cursor never existed) or 400 (which would suggest the cursor is malformed). Some APIs use 422 Unprocessable Entity, which is defensible but less specific.

The cursor-in-progress problem

For long-running pagination, the question of what happens when the underlying data changes during the iteration is a separate concern from TTL but interacts with it. The two approaches are snapshot pagination (the cursor sees a consistent point-in-time view) and live pagination (the cursor sees current data, which may include rows inserted after iteration started or skip rows deleted during iteration).

Snapshot pagination is more correct but requires either an open transaction (which is expensive at long TTL) or a captured row-ID list (which is bounded but limits scalability). For most B2B APIs, live pagination with documentation of the semantics is preferable.

The cursor TTL is the boundary between these models. Short TTLs (under an hour) can use open transactions; longer TTLs have to use captured-state or accept live-pagination semantics.

What we use across the four products

DocuMint, CronPing, FlagBit, and WebhookVault use stateless cursors with the schema-version envelope. The TTLs are effectively unlimited for the current schema, and migration changes are explicit (every cursor includes the schema version, and old-version cursors return 410 with a clear message). This minimizes server-side state and gives customers predictable behavior across iteration patterns.

The exception is WebhookVault delivery exports, which use a stateful cursor with 24-hour TTL because the export captures a consistent snapshot of deliveries from a particular subscription. This is the case where snapshot semantics matter (a customer wants to export all deliveries from yesterday without including ones that arrived during the export), and the 24-hour TTL is enough to accommodate a slow export with retries.

Three patterns that we tested and rejected: silent cursor regeneration (the failure mode is integration loops), per-request cursor renewal (adds an extra round-trip without solving a real problem), and shared cursor across endpoints (creates coupling between unrelated pagination flows).

The deeper observation is that cursor TTL is one of the small decisions that determines whether an API feels stable across customer use cases. Customers do not document their pagination patterns, do not test edge cases, and frequently re-use cursors in ways the documentation does not endorse. The TTL has to assume customers will do all of this and pick a value that fails gracefully across the distribution.

Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) keep the lights on.

Read more