Pagination, idempotency and versioning

HTTP & the web · Advanced · 14 min

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.

Check yourself

  1. Why does an idempotency key need to stay the same across retries?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc9110.html#name-conditional-requests

Builds on

Unlocks

Nothing builds on this yet.