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-Control values 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-age and s-maxage set 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.

REST API endpoint classification Decision flowchart: Is the response identical for all users? If no, use private scope. If yes, does the operation mutate state? If yes, use no-store. If no, use public scope with s-maxage. Incoming API request Same response for all users? Yes No private, no-cache Mutates state? Yes no-store or no-cache No public, s-maxage=N
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-age is non-zero and the entry is still fresh from the browser’s perspective.
  • Inspect Response Headers on the second GET and compare ETag values. A matching ETag with Status: 304 means revalidation succeeded and the server confirmed the resource has not changed. A matching ETag with Status: 200 served from the CDN means the write was not reflected in the cache.
  • The CF-Cache-Status: HIT or X-Cache: HIT headers 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: Authorization on a public endpoint. If your CDN sees Vary: Authorization, it creates a separate cache entry per distinct Authorization value, which effectively disables shared caching. Remove Vary: Authorization and use private scope instead for authenticated responses.

  • Framework-injected directives colliding with yours. Express, Django, Rails, and many frameworks emit a default Cache-Control header. 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) or Cache-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-widgets

    Then 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-maxage and stale-while-revalidate are treated identically by RFC 9111 across protocol versions. However, some older CDN nodes running HTTP/1.1 to origin may not forward stale-while-revalidate correctly — 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.


Back to Header Stacking and Directive Precedence