Reference · Performance & Evolution

Caching, Pagination & Versioning

CachingPaginationVersioningStatus codesHTTP methods

Published

The compressed essence for quick pre-interview review. Three things that decide whether an API stays fast and stays alive as it grows.

Caching & conditional requests

Cacheability is a core REST constraint: responses label themselves, intermediaries reuse them, round-trips vanish. MDN · RFC 9110.

Cache-Control directiveMeaning
max-age=NFresh for N seconds; serve without revalidating.
s-maxage=NFreshness for shared caches (CDN/proxy); overrides max-age there.
publicAny cache, including shared, may store it.
privateOnly the end-user’s browser may store it — never a shared cache.
no-storeNever store anywhere. For sensitive data.
no-cacheMay store, but must revalidate with the origin before each reuse.
must-revalidateOnce stale, must revalidate — no serving stale on error.
stale-while-revalidate=NServe stale instantly for up to Ns while refreshing in the background.

Expires is the legacy absolute-date equivalent of max-age; Cache-Control wins where both appear.

Validators — freshness ran out, is it still good?
ETag is an opaque version tag (strong, or weak W/”…”). Client echoes it in If-None-Match; if unchanged the server returns 304 Not Modified with no body — the cheap win. Last-Modified + If-Modified-Since is the timestamp-based fallback.
Conditional writes — optimistic concurrency
GET hands back an ETag → client sends If-Match: ”…” on PUT/PATCH → server returns 412 Precondition Failed if the resource changed underneath. Prevents lost updates without locking. If-None-Match: * = create only if it doesn’t already exist.
Vary — partition the cache key
Vary tells caches which request headers change the response (Accept, Authorization). Pitfall: authenticated responses must be private/no-store or Vary: Authorization — otherwise a shared cache serves one user’s data to another.

Pagination

Two strategies, one decision. Pick by access pattern, not habit. GitHub · Stripe.

 Offset / limitCursor / keyset
Deep-page costO(offset) scan-and-discardflat, index-backed
Consistency under writesdups & skips as rows shiftstable
Jump-to-pageyesno
Exact totaleasyexpensive
Best forhuman page UIslarge streaming feeds
Cursor query pattern
WHERE (sort_key, id) > last ORDER BY sort_key, id LIMIT n. Hand back an opaque cursor (base64-encoded) plus has_more. The tuple comparison breaks ties on id so no row is skipped or repeated. Keep sort + filter stable for a cursor’s lifetime, or it points at nothing meaningful.
Totals
Exact counts get expensive at scale — omit or approximate them rather than scan the table on every page.
Exemplars
GitHub: ?page= + a Link header (rel=“next”/“prev”/“last”), and before/after cursors. Stripe: starting_after/ending_before + has_more.

Versioning & evolution

The skill is changing an API thousands of clients depend on without breaking them. RFC 8594 (Sunset).

Breaking — needs a new versionNon-breaking — ship freely
Remove or rename a fieldAdd an optional request field
Change a field’s type or meaningAdd a response field
Make an optional field requiredAdd a new endpoint
Tighten validation; change defaultsAdd an enum value (if clients tolerate unknowns)
Remove an endpoint or enum value 
Where to put the versionProsCons
URI path /v1/…Visible; trivial to route, test, cacheCouples version to URL; “not pure REST”
Custom header API-VersionClean, stable URLsInvisible; easy to forget; hard to test in a browser
Media type application/vnd.x+jsonMost RESTful; HATEOAS-friendlyComplex; poor tooling support
Senior rules
Prefer additive evolution + the tolerant reader (clients ignore unknown fields). Cut a version only on real breakage. Expose only major versions. Pin existing clients — Stripe pins a version per account; GitHub uses a date-based version header.
Deprecation lifecycle
announce → parallel-run old & new → emit Deprecation + Sunset headers (RFC 8594) → publish a migration guide with a long window → monitor real usage → remove.