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
HTTP caching headers tell browsers and CDNs how long to cache a response, when to revalidate, and whether the response can be served from a shared cache. Properly configured headers reduce latency, lower origin load, and improve Core Web Vitals. The solution below covers Cache-Control, ETag, Last-Modified, and stale-while-revalidate for both API responses and static assets.
When to Use This
- Serving static assets (JS, CSS, images, fonts) that change infrequently
- API responses that are the same for all users or change at predictable intervals
- Any response that benefits from CDN edge caching See also Node.js Caching with Redis: Cache-Aside and TTL Patterns.
Prerequisites
- A web server or framework that lets you set response headers
- Basic understanding of HTTP request/response cycle
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);
});
The client sends If-None-Match: "<etag>" on subsequent requests. If the ETag matches, the server returns 304 Not Modified with no body — the client uses its cached copy.
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).toUTCString();
if (req.headers["if-modified-since"] === lastModified) {
return res.status(304).end();
}
res.setHeader("Last-Modified", lastModified);
res.setHeader("Cache-Control", "public, max-age=300");
res.json(article);
});
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);
});
The CDN serves the cached response for 60 seconds. Between 60-360 seconds, it serves the stale response while fetching a fresh copy in the background.
6. Python / FastAPI Example
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
import hashlib
import json
app = FastAPI()
app.mount("/static", StaticFiles(directory="public", max_age=31536000), name="static")
@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,
},
)
How It Works
max-age— the number of seconds the response is considered fresh. The browser serves from cache without revalidation during this period.public— allows shared caches (CDNs, proxies) to store the response. Useprivatefor user-specific data.immutable— tells the browser the response will never change during its freshness lifetime, skipping conditional revalidation entirely.ETag— a content fingerprint. The client sendsIf-None-Matchon subsequent requests; a match returns304 Not Modified.stale-while-revalidate— aftermax-ageexpires, the CDN serves stale content while fetching a fresh copy asynchronously, eliminating latency for the user.
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
-
For a deeper guide, see Complete Guide to GraphQL Caching.
-
Hash filenames for static assets — enables
immutablecaching withmax-age=31536000 -
Use
no-storefor sensitive data — billing, auth tokens, personal information -
Set
Varycorrectly — omittingAccept-Encodingcauses compressed and uncompressed responses to collide in cache -
Use
stale-while-revalidatefor APIs — eliminates user-facing latency during revalidation
Common Mistakes
- Caching user-specific responses with
public— leaks data between users through the CDN - Setting
max-age=0withoutno-cache—max-age=0forces revalidation but still stores the response;no-storeprevents storage - Forgetting
Vary: Accept-Encoding— a gzipped response cached for a client that doesn’t support gzip causes errors - Using
Expiresinstead ofCache-Control—Expiresis HTTP/1.0 and less flexible; preferCache-Control
Troubleshooting
- Cache and database are out of sync: define a TTL or invalidation policy.
- Hit rate dropped after a deployment: check cache key generation and serialization changes. A new version may use different keys.
- Cold cache causes thundering herd: use cache warming, request coalescing, or single-flight patterns for hot keys.
- Memory usage grows uncontrollably: set max memory policies, eviction thresholds, and key expiration. Audit large values.
- Stale data served to users: implement cache invalidation on write and cache-bust URLs for static assets.
Quick Reference
- Main command: run the base solution from the article and verify the expected result.
- Validation: confirm tests pass and key metrics did not degrade.
- Rollback: if something fails, revert the change and consult the Troubleshooting section.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the http and caching guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply http cache-control headers for apis and static assets when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
What is the difference between no-cache and no-store?
no-cache stores the response but requires revalidation before use. no-store prevents storage entirely. Use no-store for sensitive data.
Should I use ETag or Last-Modified?
ETag is more precise (content hash vs. timestamp). Use both — clients that support ETag use it; others fall back to Last-Modified.
How long should I cache static assets?
One year (max-age=31536000) with immutable if filenames are content-hashed. Otherwise, use a shorter TTL with revalidation.
Does stale-while-revalidate work in browsers?
It works in Chrome and Firefox. Safari ignores it. CDNs like Cloudflare and Fastly support it regardless of browser.
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