These patterns separate an API that survives real clients, retries and change from one that breaks under them.
Pagination. Never return unbounded lists.
- Offset (
?limit=20&offset=40) is simple and allows jumping to page N, but it's slow on big tables and items shift when data changes. - Cursor pagination (
?limit=20&after=eyJpZCI6NDJ9) passes an opaque token marking the last item seen. It's stable and fast with an index, which suits feeds and infinite scroll. - Return the next cursor in the body or in a
Link: <...>; rel="next"header.
Idempotency keys. POST isn't idempotent, but networks fail mid-request. The client generates a unique Idempotency-Key header per logical operation; the server stores the key with the result and, on a retry with the same key, returns the stored response instead of charging twice. Payment APIs such as Stripe work this way.
Optimistic concurrency. Return an ETag with a resource; clients send If-Match: "v7" on PUT or PATCH, and the server returns 412 Precondition Failed if someone else changed it first. No lost updates.
Rate limiting. Return 429 Too Many Requests with Retry-After, and document the limits. Clients should retry with exponential backoff and jitter.
Long operations. Return 202 Accepted with a URL to poll for status.
Versioning. Avoid breaking changes: adding fields is safe, removing or renaming them isn't. When a break is unavoidable, version in the path (/v2/) or with a header or date-based version, run both for a deprecation period, and announce it with Deprecation and Sunset headers.
Example
// Client: safe retry of a POST with an idempotency key and backoff
import { randomUUID } from "node:crypto";
async function createPayment(body, attempts = 4) {
const key = randomUUID(); // same key for every retry
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch("https://api.example.com/v1/payments", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(body),
});
if (res.status !== 429 && res.status < 500) return res; // done (or a 4xx to fix)
const wait = Number(res.headers.get("retry-after")) * 1000 || 2 ** i * 250;
await new Promise((r) => setTimeout(r, wait + Math.random() * 100));
} catch { // network error: retry is safe
await new Promise((r) => setTimeout(r, 2 ** i * 250));
}
}
throw new Error("payment failed after retries");
}Remember
- Prefer cursor pagination for large or changing collections
- Idempotency-Key makes POST retries safe
- If-Match + ETag prevents lost updates (412 on conflict)
- 429 + Retry-After; clients back off with jitter
Watch out for
- Generating a new idempotency key for each retry, which defeats its purpose.