Cache-Control Best Practices for REST APIs
REST APIs frequently produce stale data in production when Cache-Control headers are misconfigured. The most common root cause is conflating browser caching with shared CDN caching — or applying public to user-specific endpoints. This creates race conditions where GET requests return cached 200 OK responses despite recent writes, or where different users receive each other’s personalized data.
Prerequisite Concepts
Before working through the steps below, make sure you understand:
- How Header Stacking and Directive Precedence resolves conflicts when multiple directives arrive in the same response, because framework middleware and reverse proxies frequently inject their own
Cache-Controlvalues that can override yours. - The difference between public and private cache scope, which determines whether a CDN or shared proxy is permitted to store and serve a response at all.
- How
max-ageands-maxageset independent TTLs for browser caches and shared caches respectively, so you can keep CDN TTLs long while forcing browsers to revalidate on every request.
Step-by-Step Resolution
Step 1 — Classify every endpoint
Map each endpoint to one of three classes before choosing a directive string. Mixing classes on the same endpoint is the single most common source of caching bugs.
| Endpoint class | Example | Directive strategy |
|---|---|---|
| Public read | GET /products |
public, s-maxage=60, stale-while-revalidate=300 |
| Private / authenticated | GET /account/orders |
private, no-cache |
| Write operation | POST /orders, DELETE /orders/42 |
no-store or no-cache |
Step 2 — Emit validators on every cacheable response
Without ETag or Last-Modified response headers, caches have no way to revalidate a stale entry. They must either serve the stale copy or fetch a full new one. Always derive the ETag from a content hash, not a timestamp, so all load-balanced origin nodes produce identical values for the same content — timestamp-based ETags break on multi-node setups.
HTTP/2 200
Cache-Control: public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300
ETag: W/"a3f8c2"
Vary: Accept-Encoding
Content-Type: application/json
Step 3 — Apply the directive string at the origin or reverse proxy
Public read endpoints — CDN caches for 60 seconds, browsers always revalidate, CDN may serve stale for up to 5 minutes while asynchronously refreshing:
Cache-Control: public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300
Nginx:
location /api/v1/products {
proxy_hide_header Cache-Control;
add_header Cache-Control "public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300" always;
}
Express.js:
res.set('Cache-Control', 'public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300');
Cloudflare Workers:
response.headers.set('Cache-Control', 'public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300');
Private / authenticated endpoints — browser may cache locally, shared caches must not store:
Cache-Control: private, no-cache
The no-cache directive here means the browser will revalidate with the origin on every use (sending If-None-Match if an ETag is present), rather than serving the locally stored copy unvalidated. This is distinct from no-store, which prohibits caching entirely.
Write-operation responses — nothing should be cached; purge related entries immediately:
Cache-Control: no-store
After any POST, PUT, PATCH, or DELETE that succeeds, trigger a cache purge for the affected resource URL. Prefer tag-based invalidation (see tag-based cache invalidation patterns) when multiple URLs reference the same underlying data.
Step 4 — Reproduce and diagnose the stale-after-write problem
If you suspect stale responses are being served after a write, run this sequence manually:
# 1. Fetch the resource and capture the ETag
curl -sI -H "Accept: application/json" \
https://api.example.com/v1/resource/42 \
| grep -iE "cache-control|etag|age|x-cache|cf-cache-status"
# 2. Mutate the resource
curl -s -X PUT https://api.example.com/v1/resource/42 \
-H "Content-Type: application/json" \
-d '{"status":"updated"}'
# 3. Immediately re-fetch and compare the ETag and Age header
curl -sI -H "Accept: application/json" \
https://api.example.com/v1/resource/42 \
| grep -iE "cache-control|etag|age|x-cache|cf-cache-status"
If the ETag is unchanged and Age is greater than zero after the write, the CDN is serving the pre-write cache entry. The Age value (in seconds) indicates how long the CDN has held that entry.
Step 5 — Verify with browser DevTools
Open the Network tab with caching enabled (do not tick “Disable cache”). Issue the request sequence: one GET, one PUT, one GET.
- On the second GET, look at Size column. If it shows
(disk cache)or(memory cache), the browser is not revalidating —max-ageis non-zero and the entry is still fresh from the browser’s perspective. - Inspect Response Headers on the second GET and compare
ETagvalues. A matchingETagwithStatus: 304means revalidation succeeded and the server confirmed the resource has not changed. A matchingETagwithStatus: 200served from the CDN means the write was not reflected in the cache. - The
CF-Cache-Status: HITorX-Cache: HITheaders confirm the CDN, not the origin, served the response.
Expected Output
A correctly configured public endpoint responding after successful write-through looks like:
HTTP/2 200
Cache-Control: public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300
ETag: W/"b9d441"
Age: 0
CF-Cache-Status: MISS
Vary: Accept-Encoding
Age: 0 and CF-Cache-Status: MISS confirm this is a fresh origin response. On the next request within 60 seconds, Age will increment and CF-Cache-Status will change to HIT. After a write and purge, the next request will again show Age: 0 and MISS.
For a conditional request after the browser has a stored ETag:
GET /api/v1/resource/42 HTTP/2
If-None-Match: W/"b9d441"
HTTP/2 304
Cache-Control: public, s-maxage=60, max-age=0, must-revalidate, stale-while-revalidate=300
ETag: W/"b9d441"
A 304 Not Modified response carries no body — the browser reuses its stored copy. This pattern saves bandwidth while keeping freshness and validation in sync.
Edge Cases
-
Vary: Authorizationon a public endpoint. If your CDN seesVary: Authorization, it creates a separate cache entry per distinctAuthorizationvalue, which effectively disables shared caching. RemoveVary: Authorizationand useprivatescope instead for authenticated responses. -
Framework-injected directives colliding with yours. Express, Django, Rails, and many frameworks emit a default
Cache-Controlheader. If both the framework and your code set the header, the resulting stacked directive string may conflict. Strip upstream values in your reverse proxy before adding your own, as described in the header stacking and directive precedence guide. -
CDN purge propagation latency. Even after issuing a purge, edge nodes in distant regions can take 100ms–30 seconds to evict the stale entry. Use
Surrogate-Key(Fastly) orCache-Tag(Cloudflare) headers to group related URLs under a single invalidation tag, reducing the surface area of a purge operation:Surrogate-Key: resource-42 category-widgets Cache-Tag: resource-42,category-widgetsThen purge by tag after any write:
# Fastly fastly purge --service-id YOUR_SERVICE_ID --key "resource-42" # Cloudflare (via API) curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"tags":["resource-42"]}' -
HTTP/1.1 vs HTTP/2 behavior.
s-maxageandstale-while-revalidateare treated identically by RFC 9111 across protocol versions. However, some older CDN nodes running HTTP/1.1 to origin may not forwardstale-while-revalidatecorrectly — verify with the CDN’s own documentation and check response headers from the edge.
Frequently Asked Questions
Can I use public caching on an endpoint that requires authentication?
Only when every authenticated user receives an identical response — for example, a publicly readable resource list that happens to be served over an authenticated connection. If the response body varies per user, use private instead. Serving another user’s data from a shared cache is a security vulnerability, not just a caching bug.
What is the difference between no-cache and no-store for APIs?
no-cache permits the cache to store the response but requires explicit revalidation with the origin before it is used. The round-trip to origin still occurs, but if the content is unchanged the server sends 304 Not Modified with no body — saving bandwidth. no-store prohibits storage entirely: no body is retained anywhere in the cache chain. Use no-cache for high-frequency polling where conditional requests save bandwidth; use no-store only for genuinely sensitive payloads such as payment tokens or session data.
Why does my CDN keep serving stale data after a PUT request?
CDNs have no automatic mechanism to detect that a write mutated the resource at a different URL. The CDN’s TTL clock started when it first cached the entry and only resets when that TTL expires or an explicit purge is issued. You must purge the affected URL (or its surrogate keys) immediately after each successful write.
Related
- How to Combine Cache-Control Directives Safely explains exactly which directive combinations are safe to stack and which produce undefined behavior in production CDNs.
- What Happens When max-age Expires covers the revalidation sequence triggered when a cached API response ages out of freshness.
- Tag-Based Cache Invalidation Patterns details how to group related API resource URLs under a single invalidation tag for fast, targeted purges.
- Using Vary Accept-Encoding Without Fragmenting Cache shows how to set
Varycorrectly on compressed API responses without multiplying CDN cache entries.