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):
GET /orders?limit=20&offset=40SQL: 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
offsetrows, so deep pages are slow, roughlyO(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=20isOFFSET 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:
GET /orders?limit=20&after=eyJpZCI6MTAwMH0SQL: 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_atrequires 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 equivalentORcondition 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 noneFetching 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:
- Receive the request with key
K. - Atomically look up
Kin an idempotency store. If absent, insertKwith statusin_progress(a unique constraint makes concurrent duplicates fail). - Execute the operation.
- Store the response (status and body) under
Kwith statuscompleted. - On a repeat with the same key, return the stored response without executing again. If the stored entry is still
in_progress, return409 Conflictor 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 chargeDetails:
- If the server crashes after inserting
Kbut before storing the response, the entry staysin_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 (
422or400). - 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
DeprecationandSunsetresponse 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 Requestswith aRetry-Afterheader (seconds or an HTTP date). - Informational headers are common but not universally named. Widely seen names are
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset. An IETF draft definesRateLimit-Limit,RateLimit-Remaining, andRateLimit-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:
{
"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
typeURI or acodefield) distinct from the human message, which can change or be localized. - Field-level validation details, for example a list of
{field, message}entries for a400or422. - 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:
5xxand429are candidates, most4xxare 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-Afterheader stating when to retry, and ideally rate-limit headers and an error body identifying the limit that was exceeded.
