HTTP Cache-Control Headers for APIs and Static Assets
Set Cache-Control, ETag, and Last-Modified headers to control browser and CDN caching for API responses and static assets
Overview
HTTP caching headers tell browsers and CDNs how long to cache a response, when to revalidate, and whether a shared cache may store it at all. A well-set Cache-Control cuts latency, lowers origin load, and improves Core Web Vitals; a missing or wrong one either leaks private data through shared caches or defeats caching entirely. This recipe covers Cache-Control, ETag, Last-Modified, and stale-while-revalidate for API responses and static assets, with working examples for nginx, Express, and FastAPI.
When to Use
- Serving static assets (JS, CSS, images, fonts) with content hashes in the filename — they can be cached for a year with
immutable. - Public API responses that are identical for every user or change at predictable intervals.
- SSR pages behind a CDN —
s-maxageis how you give the edge a longer freshness window than browsers get. - Large or expensive responses where conditional requests (
ETag,Last-Modified) save bandwidth by returning304 Not Modified. - For server-side object caching rather than HTTP caching, see Node.js Caching with Redis: Cache-Aside and TTL Patterns.
When NOT to Use
- Authenticated or personalized responses — never mark them
public; useprivate, no-cacheorno-storeso a shared cache can’t serve one user’s data to another. - Responses that change on every request — real-time feeds, CSRF tokens, nonces. For those,
no-storeis the only honest answer; anything else is a promise some cache may act on. - HTML entry points that must reflect every deploy — prefer
no-cachewithETagover a longmax-age, or users will hold a stale shell that references deleted hashed assets.
Prerequisites
- A server or framework where you control response headers — the examples use nginx, Express, and FastAPI.
- You can read request and response headers; nothing fancier is needed.
Solution
1. Static Assets — Long Cache with Immutable
Static assets with content hashes in filenames can be cached aggressively:
# nginx.conf
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
// Express.js
app.use(express.static("public", {
maxAge: "1y",
setHeaders: (res, path) => {
if (path.endsWith(".js") || path.endsWith(".css")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
},
}));
The immutable flag tells the browser to never revalidate — the filename changes when the content changes (e.g., app.abc123.js).
2. API Responses — Short Cache with Revalidation
// Express.js — cache API responses for 60 seconds with revalidation
app.get("/api/products", async (req, res) => {
const products = await getProducts();
res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
res.json(products);
});
// No caching for user-specific data
app.get("/api/users/me", authMiddleware, async (req, res) => {
const user = await getUser(req.userId);
res.setHeader("Cache-Control", "private, no-cache");
res.json(user);
});
3. ETag for Conditional Requests
import crypto from "crypto";
app.get("/api/products", async (req, res) => {
const products = await getProducts();
const etag = `"${crypto.createHash("sha256").update(JSON.stringify(products)).digest("hex").slice(0, 16)}"`;
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.setHeader("ETag", etag);
res.setHeader("Cache-Control", "public, max-age=60");
res.json(products);
});
On later requests the client echoes that fingerprint back as If-None-Match: "<etag>". When the ETag matches, the server answers with a bare 304 Not Modified — no body, no re-serialization — and the client keeps its cached copy. For this to work, the hashed payload has to be stable: a timestamp or a request-scoped field sneaking into the serialization produces a different ETag on every request, and the conditional-fetch optimization dies.
4. Last-Modified for Conditional Requests
app.get("/api/articles/:id", async (req, res) => {
const article = await getArticle(req.params.id);
const lastModified = new Date(article.updatedAt);
const ifModifiedSince = req.headers["if-modified-since"];
if (ifModifiedSince && new Date(ifModifiedSince) >= lastModified) {
return res.status(304).end();
}
res.setHeader("Last-Modified", lastModified.toUTCString());
res.setHeader("Cache-Control", "public, max-age=300");
res.json(article);
});
If-Modified-Since is a threshold, not an exact match — compare parsed dates, because a client or intermediate cache can legitimately send a later timestamp. When both validators exist, ETag/If-None-Match wins and Last-Modified is ignored.
5. stale-while-revalidate for Background Refresh
app.get("/api/trending", async (req, res) => {
const data = await getTrending();
// Cache for 60s, then serve stale for up to 300s while revalidating
res.setHeader(
"Cache-Control",
"public, max-age=60, stale-while-revalidate=300"
);
res.json(data);
});
During the first 60 seconds the CDN answers straight from cache. After that it keeps serving the same copy for up to five more minutes, while it quietly fetches a fresh one in the background — so no visitor ever waits on the origin round-trip.
6. Python / FastAPI Example
StaticFiles doesn’t accept a max_age argument, so mount it normally and add the header with a middleware:
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
import hashlib
import json
app = FastAPI()
app.mount("/static", StaticFiles(directory="public"), name="static")
@app.middleware("http")
async def cache_headers(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
@app.get("/api/products")
async def get_products(request: Request):
products = await fetch_products()
body = json.dumps(products, default=str)
etag = f'"{hashlib.sha256(body.encode()).hexdigest()[:16]}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return Response(
content=body,
media_type="application/json",
headers={
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
"ETag": etag,
},
)
The runnable versions of these configs — the full nginx.conf, the Express app, and the FastAPI app — live in the companion repository.
How It Works
The right combination depends on what you’re serving:
| Directive combination | Applies to | What it does |
|---|---|---|
public, max-age=31536000, immutable | Hashed static assets | Year-long cache everywhere; never revalidates |
public, max-age=60, stale-while-revalidate=300 | Public API reads | Fresh 60s, then stale up to 300s while refetching |
s-maxage=3600, max-age=60 | SSR pages behind a CDN | Edge caches 1 hour; browser revalidates every minute |
private, no-cache | Authenticated data | Browser may store it but must revalidate; shared caches skip it |
no-store | Billing, tokens, personal data | Nothing is stored by any cache |
max-age— the number of seconds the response is considered fresh. The browser serves from cache without revalidation during this period.public/private—publicopens the response to shared caches (CDNs, proxies);privatelimits storage to the end user’s browser.immutable— tells the browser the response will never change during its freshness lifetime, skipping conditional revalidation entirely.ETag— a fingerprint the server computes from the response payload. The client echoes it inIf-None-Match; on a match, the server replies304 Not Modified.stale-while-revalidate— oncemax-agelapses, the CDN keeps answering with the stale copy while it refetches asynchronously, so nobody pays the latency of a synchronous refresh.
Variants
No-Store for Sensitive Data
app.get("/api/user/billing", authMiddleware, async (req, res) => {
res.setHeader("Cache-Control", "no-store");
res.json(billingData);
});
no-store prevents any cache — browser, CDN, or proxy — from storing the response.
Vary Header for Content Negotiation
app.get("/api/products", (req, res) => {
res.setHeader("Vary", "Accept-Encoding, Accept-Language");
res.setHeader("Cache-Control", "public, max-age=300");
// Response varies by encoding (gzip, br) and language
res.json(products);
});
Surrogate-Control for CDN-Specific Caching
res.setHeader("Surrogate-Control", "max-age=3600");
res.setHeader("Cache-Control", "max-age=60");
CDNs use the longer Surrogate-Control TTL, while browsers use the shorter Cache-Control TTL.
Best Practices
- Hash filenames for static assets — that’s what makes a year-long
immutablepolicy safe:app.a1b2c3.jschanges name whenever its bytes change. - Use
no-storefor sensitive data — billing info, auth tokens, anything personal. - Set
Varycorrectly — omittingAccept-Encodingcauses compressed and uncompressed responses to collide in cache. - Use
stale-while-revalidatefor APIs — eliminates user-facing latency during revalidation. - For GraphQL-specific cache strategies, see the Complete Guide to GraphQL Caching.
Common Mistakes
- Caching user-specific responses with
public— leaks data between users through the CDN. - Setting
max-age=0when you meanno-store— a response withmax-age=0still gets stored and only forces revalidation; onlyno-storekeeps it out of every cache. - Forgetting
Vary: Accept-Encoding— a gzipped response cached for a client that doesn’t support gzip causes errors. - Relying on
Expiresinstead ofCache-Control— the HTTP/1.0 date header breaks with wrong client clocks;Cache-Controlcovers everything it does and more.
Troubleshooting
- Browser never caches despite
max-age: the request carriesAuthorizationor the response setsSet-Cookie— both make browsers and most CDNs refuse to store unlesspublicors-maxageexplicitly allows it. Check the “Disk cache” column in DevTools Network. 304 Not Modifiednever happens: the ETag changes on every request because the hashed payload includes unstable fields (timestamps, request IDs). Hash only the data that defines the response.- CDN keeps serving stale content after a deploy: origin
max-agewas too long and there’s nos-maxage/Surrogate-Controlsplit, or the CDN was never purged. Shorten the shared TTL or purge by cache tag. - Safari still waits for revalidation: it ignores
stale-while-revalidate— treat the directive as a latency optimization for browsers and CDNs that support it, never as a correctness guarantee. - Client gets a gzip body it can’t decode:
Vary: Accept-Encodingis missing, so a compressed response cached once gets replayed to every client. - One user sees another user’s data: a response was marked
publicwithoutVary: Authorization, and a shared cache stored it. Switch toprivate, no-cacheand audit which routes setpublic.
Further Reading
- HTTP caching — MDN — the canonical reference for caching semantics and directive behavior.
- RFC 9111: HTTP Caching — the spec itself: where
Cache-Control, validators, and shared caches are defined. - HTTP caching — web.dev — practical guide to
Cache-Control,ETag, and invalidation patterns. - Cache concepts — Cloudflare docs — how a real CDN interprets these headers, including
s-maxageand purge.
Frequently Asked Questions
What is the difference between no-cache and no-store?
no-cache stores the response but requires revalidation before reuse; no-store prevents storage entirely. Reach for no-store whenever the response carries sensitive data like tokens or billing.
Should I use ETag or Last-Modified?
ETag is more precise — a content hash beats a one-second-resolution timestamp. Send both: clients that support If-None-Match use the ETag, and older clients fall back to If-Modified-Since.
How long should I cache static assets?
One year (max-age=31536000) with immutable, but only if filenames carry a content hash. Without hashed filenames, use a shorter TTL with revalidation or deploys will serve stale files.
Does stale-while-revalidate work in browsers?
Chrome and Firefox honor it; Safari doesn't. CDNs such as Cloudflare or Fastly apply it no matter what the browser does.
What is the difference between max-age and s-maxage?
max-age applies to every cache; s-maxage applies only to shared caches (CDNs, proxies) and overrides max-age for them. s-maxage=600, max-age=60 lets a CDN cache for 10 minutes while browsers revalidate every minute.
How do I invalidate cached responses?
For the browser, no-cache plus ETag forces revalidation and a new ETag forces a fresh fetch. For a CDN, use its purge API — tag-based purging invalidates groups of URLs by cache tag. See CDN Cache Invalidation Strategies for surrogate keys and purge patterns.
How do I cache authenticated requests?
Set Cache-Control: private so shared caches never store user-specific responses, and Vary: Authorization if the response differs by credentials. Never put responses containing tokens or session IDs in a shared cache.
How do I implement cache busting for hashed assets?
Embed the content hash in the filename (app.a1b2c3d4.js) and hand it out with public, max-age=31536000, immutable. Change a byte and the hash — and therefore the URL — changes, so the browser fetches it as something new. Keep the HTML that references it on no-cache so browsers always pick up the new filenames.
How do I debug caching issues?
Start with curl -I on the URL and read what comes back: Cache-Control is the policy, Age is how many seconds a shared cache has held the copy (above max-age it's stale), and X-Cache reports hit vs. miss when a CDN is in front. DevTools' Network tab shows the same info, and a request sent with Cache-Control: no-cache forces revalidation end to end.
How do Vary headers affect caching?
Vary lists the request headers that change the response: Vary: Accept-Encoding keeps gzip and brotli copies separate, Vary: Accept keeps JSON and HTML separate, Vary: Origin separates CORS responses per origin. Never use Vary: * — it disables caching — and avoid Vary: User-Agent, which fragments the cache into thousands of keys.
Related Resources
Implement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
PatternCache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.
RecipeCDN Cache Invalidation Strategies and Patterns
Implement CDN cache invalidation using purge APIs, surrogate keys, tag-based invalidation, and versioned URLs to keep content fresh