Caching is the cheapest performance optimization available to a REST API, and it's also the one most APIs get wrong by omission — no cache headers at all, which tells every intermediary and client to make its own guess, and guesses trend toward "don't cache anything" or, worse, "cache everything for a browser's default heuristic amount of time." HTTP has had a mature caching model since before REST was named as an architectural style; the model works, but it only works if you actually emit the headers that drive it.
Cache-Control is the primary lever
Cache-Control tells caches (browser, CDN, reverse proxy) how long a response is fresh and who's allowed to store it. max-age=300 means fresh for five minutes; public means shared caches like a CDN can store it; private restricts storage to the end user's own browser, which matters for any response that varies per-authenticated-user. no-store is the strongest instruction — don't cache this anywhere, ever — appropriate for anything containing payment details or one-time tokens. Getting private vs public wrong is a real security bug: a public response containing one user's account balance, cached by a shared CDN edge, can be served to the next user who hits the same URL.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60, must-revalidate
ETag: "a1b2c3d4-v7"
Vary: Authorization
{"id": 4471, "status": "open", "total": 1520.00}
Validation caching vs. expiration caching
Expiration caching (max-age) is fast but stale-tolerant: the client uses the cached copy without asking the server anything until it expires, which is fine for data that changes rarely (a product catalog) and wrong for data that changes often (an order's live status). Validation caching solves the freshness problem differently: the client always asks the server "has this changed?" via a conditional request, and the server answers with a cheap 304 Not Modified if nothing has, avoiding the cost of re-sending and re-parsing the full payload while still guaranteeing the client never acts on stale data. Most real APIs want both — a short max-age to avoid asking on every single request, backed by ETag validation for the case where the client asks again before max-age has passed.
Vary tells shared caches what else matters
A CDN caching a response keyed only on the URL will happily serve user A's cached response to user B if the response actually differs by Authorization header, Accept-Language, or a custom tenant header. Vary: Authorization tells the cache to key its storage on that header too, so two different tokens get two different cache entries. Forgetting Vary is the same class of bug as forgetting private — a cache-poisoning-adjacent leak that shows up as "user reports seeing someone else's data," usually from a support ticket rather than a test.
Cache-Control: max-age=60, stale-while-revalidate=30 serves the stale cached copy instantly while asynchronously refetching in the background, for up to 30 seconds past expiry. Users get a fast response every time, and the cache self-heals within a bounded window — a good default for read-heavy endpoints where a few seconds of staleness is harmless.
What a CDN actually does differently from a browser
A browser cache is per-user and per-device; a CDN edge cache is shared across every user hitting that edge node, which is exactly why the public/private distinction and Vary header matter so much more at that layer. CDNs also generally respect s-maxage as an override specifically for shared caches, letting you serve a longer TTL to the CDN than you'd want a browser holding onto locally — useful when you can purge the CDN on write but can't force every browser to refetch.
POST, PUT, PATCH, and DELETE responses are not cacheable by default under HTTP semantics, and that's the right default — a cached 201 Created response replayed to a later GET on that URL is meaningless at best and a stale-data bug at worst. If you need to serve fast reads after a write, invalidate the specific cached GET, don't try to cache the write itself.
Wrapping up
REST APIs get caching for close to free through standard HTTP headers — the cost is discipline, not infrastructure: set Cache-Control deliberately per endpoint, scope public vs private correctly for anything user-specific, declare Vary whenever a header changes the response, and pair a short max-age with ETag validation for data that needs to stay fresh without paying full payload cost on every check.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.