Guide contents

Lesson 14 of 129

API Pagination, Idempotency, and Versioning

Beyond choosing resources and methods (see REST API design), an API that many clients depend on must handle large result sets, retried requests, change over time, overload, and failures in a consistent way. This article covers five such concerns.

Pagination

Pagination returns a large collection in bounded pages. Without it, a single response can be unboundedly large, consuming memory, bandwidth, and database time.

Offset pagination

The client passes limit and offset (or page and page_size):

Text
GET /orders?limit=20&offset=40

SQL: SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 40.

  • Simple, and supports jumping to an arbitrary page.
  • Cost grows with the offset: the database must find and skip the first offset rows, so deep pages are slow, roughly O(offset + limit).
  • Unstable under writes: if a row is inserted or deleted before the current position between two requests, the next page skips or repeats items.
  • Example: page 5,000 with limit=20 is OFFSET 99980. The database reads and discards 99,980 rows to return 20.
  • A total count (COUNT(*)) can itself be expensive on large tables.

Cursor (keyset) pagination

The client passes an opaque cursor that encodes the position after the last item returned. The server uses the sort key to continue from that position:

Text
GET /orders?limit=20&after=eyJpZCI6MTAwMH0

SQL: SELECT * FROM orders WHERE id > 1000 ORDER BY id LIMIT 20, with an index on id.

  • With an index, the query seeks directly to the position, so cost per page is O(limit) independent of depth (plus the index seek).
  • Stable under inserts and deletes: new rows do not shift the pages already traversed.
  • No random access to page N, and no cheap total count.
  • The sort key must be unique and totally ordered. Sorting by a non-unique column such as created_at requires a tiebreaker: WHERE (created_at, id) > (?, ?) ORDER BY created_at, id (row-value comparison is supported by PostgreSQL, MySQL, and SQLite, and can be written as an equivalent OR condition elsewhere).
  • The cursor is treated as opaque (for example base64 of the key values, optionally signed), so the server can change its internals.
sequenceDiagram
    participant C as Client
    participant S as Server
    participant D as Database
    C->>S: GET /orders?limit=20
    S->>D: ORDER BY id LIMIT 21
    D-->>S: 21 rows
    S-->>C: 20 items, next_cursor = id of item 20
    C->>S: GET /orders?limit=20&after=cursor
    S->>D: WHERE id > cursor ORDER BY id LIMIT 21
    D-->>S: rows
    S-->>C: items, next_cursor or none

Fetching limit + 1 rows reveals whether another page exists without a count.

Aspect Offset Cursor
Deep page cost Grows with offset Constant per page
Consistency under writes Skips or duplicates Stable
Jump to page N Yes No
Implementation Trivial Needs ordered unique key and index
Fits Small datasets, admin UIs Feeds, timelines, large or changing datasets

Response conventions: return the items plus a next_cursor (or a Link header with rel="next"), and enforce a maximum limit.

Idempotency

A network call can time out with the outcome unknown: the server may or may not have processed it. The client can only retry. For GET, PUT, and DELETE, retries are safe by definition. For POST operations such as "create payment", a blind retry can charge twice.

The standard solution is an idempotency key: the client generates a unique value (for example a UUID) per logical operation and sends it in a header such as Idempotency-Key. Payment APIs such as Stripe's use this pattern, and an IETF draft standardizes the header.

Server procedure:

  1. Receive the request with key K.
  2. Atomically look up K in an idempotency store. If absent, insert K with status in_progress (a unique constraint makes concurrent duplicates fail).
  3. Execute the operation.
  4. Store the response (status and body) under K with status completed.
  5. On a repeat with the same key, return the stored response without executing again. If the stored entry is still in_progress, return 409 Conflict or wait.
sequenceDiagram
    participant C as Client
    participant S as Server
    participant DB as Idempotency store
    C->>S: POST /payments, Idempotency-Key K
    S->>DB: insert K (in_progress)
    S->>S: process payment
    S->>DB: store response for K
    S--xC: response lost
    C->>S: retry POST /payments, Idempotency-Key K
    S->>DB: lookup K
    DB-->>S: stored response
    S-->>C: same response, no second charge

Details:

  • If the server crashes after inserting K but before storing the response, the entry stays in_progress. Entries therefore carry a lock timeout, after which a retry may take over the operation, which requires the operation itself to be safe to re-run or to be checked against its own state.
  • The key must be recorded in the same transaction as the side effect where possible, or the operation itself must be idempotent, otherwise a crash between the two leaves an inconsistent state.
  • Bind the key to the request fingerprint. If the same key arrives with a different body, reject it (422 or 400).
  • Scope keys per client or account, and expire them after a retention period (for example 24 hours).
  • Messaging systems use the same idea on the consumer side. See delivery semantics and idempotency and idempotent consumers and deduplication.

Versioning

APIs change. Breaking changes include removing or renaming a field, changing a type or meaning, adding a required request parameter, or changing error behavior. Non-breaking changes include adding optional request fields, adding response fields, and adding endpoints. Clients must be written to ignore unknown response fields so that additive changes stay safe.

Strategies when a breaking change is unavoidable:

Strategy Example Notes
URL path /v1/orders, /v2/orders Explicit, easy to route and cache, visible in logs. Most common. Purists note the URL then no longer names the resource alone.
Query parameter /orders?version=2 Easy to add, easy to omit by mistake.
Header X-API-Version: 2 Clean URLs; harder to test in a browser and to cache without Vary.
Media type Accept: application/vnd.example.v2+json Content negotiation; the most REST-aligned, and the most complex for clients.
Date-based Stripe-Version: 2024-06-20 Each account pins to a date; the server applies transformations for older versions.

Practices:

  • Prefer evolving compatibly (additive changes) over new versions. Fewer versions means less to maintain.
  • Publish a deprecation policy: announce with a timeline, signal with Deprecation and Sunset response headers, monitor usage per version, and contact remaining clients before removal.
  • Keep old versions running side by side, for example by routing at the API gateway to different backends or by adapter layers that convert between versions.
  • For service-to-service contracts see API versioning and contract testing.

Rate-limit headers

Servers limit request rates to protect capacity and enforce fairness (see rate limiting algorithms). Well-behaved APIs tell clients their status so they can slow down before being rejected.

  • Rejected requests return 429 Too Many Requests with a Retry-After header (seconds or an HTTP date).
  • Informational headers are common but not universally named. Widely seen names are X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. An IETF draft defines RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset (and a policy header).
  • Clients should honor Retry-After, then retry with backoff and jitter (see timeouts, retries, backoff, and jitter).
  • Limits can apply per API key, user, IP address, or endpoint, and cost-based limits apply when requests differ greatly in cost.

Error models

A consistent error format lets clients handle failures programmatically. RFC 9457 (obsoleting RFC 7807) defines Problem Details with media type application/problem+json:

JSON
{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "Balance is 30.00, transaction requires 50.00.",
  "instance": "/payments/abc123"
}

Recommended properties of an error model:

  • The HTTP status code is correct, since intermediaries and clients rely on it.
  • A stable machine-readable error code (the type URI or a code field) distinct from the human message, which can change or be localized.
  • Field-level validation details, for example a list of {field, message} entries for a 400 or 422.
  • A request or trace ID to correlate the failure with server logs.
  • No internal details such as stack traces or SQL, which leak implementation and aid attackers.
  • Whether the error is retryable is inferable from the code: 5xx and 429 are candidates, most 4xx are not.

Interview questions

  • Why is offset pagination a problem for a feed with frequent inserts? Inserts shift row positions, so the next offset skips or repeats items, and deep offsets require scanning and discarding many rows. Cursor pagination continues from a key and is unaffected.
  • How does an idempotency key make a POST safe to retry? The server stores the response under the key on first execution and returns it for repeats, so the side effect happens once. Concurrent duplicates are blocked by an atomic insert of the key.
  • What is a breaking API change? Any change that can make a correct existing client fail: removing or renaming fields, changing types or semantics, or adding required inputs.
  • What should a 429 response contain? A Retry-After header stating when to retry, and ideally rate-limit headers and an error body identifying the limit that was exceeded.