HTTP caching for web APIs — Cache-Control headers, ETags, conditional requests,
88
84%
Does it follow best practices?
Impact
100%
25.00xAverage score across 4 eval scenarios
Passed
No known issues
IMPORTANT: Every API endpoint that serves data MUST set appropriate Cache-Control headers. This is not optional. Apply this automatically whenever you build a GET endpoint — do not wait to be asked.
When you are asked to build any API that serves products, catalog items, user profiles, search results, images, or any other data via GET, you must proactively add:
Cache-Control header on EVERY response — either caching directives or explicit no-cache/no-storeETag header and If-None-Match handling for conditional requests on cacheable endpointsVary header when responses differ by Accept, Accept-Encoding, Authorization, or other request headersno-store on endpoints that return sensitive or authenticated user dataThese headers are as fundamental as status codes. You would never return data without a proper status code — likewise, never serve a response without cache headers.
An API with no cache headers is a performance and correctness bug:
Client -> GET /api/products -> 200 OK (no cache headers)
Client -> GET /api/products -> 200 OK (same data, full round trip, wasted bandwidth)
CDN -> GET /api/products -> caches with default heuristic TTL (WRONG — stale data served)
Client -> GET /api/me -> 200 OK (no cache headers, CDN caches user data for everyone)Without explicit cache headers, browsers and CDNs use heuristic caching — they guess, and they guess wrong. Sensitive data gets shared. Stale data gets served. Fresh data gets re-fetched unnecessarily.
// WRONG — no cache headers, browser/CDN will guess
router.get("/api/products", (req, res) => {
const products = db.prepare("SELECT * FROM products").all();
res.json({ data: products });
});// WRONG — no-cache does NOT mean "don't cache"
// no-cache means "cache it but revalidate every time"
// For sensitive data you need no-store
router.get("/api/me", authMiddleware, (req, res) => {
res.set('Cache-Control', 'no-cache'); // WRONG — CDN may still store this
res.json({ data: req.user });
});// RIGHT — public catalog data, cacheable with revalidation
router.get("/api/products", (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
const products = getProducts();
res.json({ data: products });
});
// RIGHT — authenticated user data, never cache or store
router.get("/api/me", authMiddleware, (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.json({ data: req.user });
});
// RIGHT — individual product, cacheable with ETag
router.get("/api/products/:id", (req, res) => {
res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
const product = getProduct(req.params.id);
res.json({ data: product });
});public, max-age=31536000, immutable — Static assetsCache-Control: public, max-age=31536000, immutableFor CSS, JS, images with content-hashed filenames (app.abc123.js). Cache for 1 year. The immutable directive tells the browser to never revalidate — the filename changes when the content changes.
You MUST use content hashing in filenames. Without it, users get stale assets after deployments.
public, max-age=60, stale-while-revalidate=300 — Read-heavy API dataCache-Control: public, max-age=60, stale-while-revalidate=300For product catalogs, menu items, category lists — data that changes infrequently. Fresh for 60 seconds, serve stale for 5 minutes while revalidating in the background. The stale-while-revalidate directive prevents the "thundering herd" — clients get an instant response instead of all waiting for revalidation.
public, max-age=0, must-revalidate — Always-fresh data with ETagCache-Control: public, max-age=0, must-revalidateFor data that could change at any time but benefits from conditional requests. Combined with ETag, the client revalidates every request but gets a 304 Not Modified (no body) when the data hasn't changed — saving bandwidth.
private, no-cache — Per-user data that changes oftenCache-Control: private, no-cacheFor order status, notification counts, shopping cart contents. private means CDNs must not cache it. no-cache means the browser must revalidate with the server before using a cached copy. The response IS stored locally but never used without checking.
private, no-store — Sensitive/authenticated dataCache-Control: private, no-storeFor user profiles behind authentication, account details, payment information, tokens, API keys. no-store means the response must NEVER be written to any cache (disk or memory). This is the strongest directive — use it for anything that would be a privacy violation if leaked.
Critical distinction: no-cache vs no-store:
no-cache = "store it, but revalidate before using" (still stored on disk!)no-store = "never store this response anywhere"no-store. Using no-cache alone means the data is written to disk cache, which is a security risk.ETags let the server say "the data hasn't changed, use your cached copy" — saving bandwidth and reducing response times. Always add ETags to cacheable endpoints.
import crypto from "crypto";
router.get("/api/products", (req, res) => {
const products = getProducts();
const etag = `"${crypto.createHash('md5').update(JSON.stringify(products)).digest('hex')}"`;
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.set('ETag', etag);
// Check if client's cached version is still current
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified — no body sent, saves bandwidth
}
res.json({ data: products });
});"abc123", not abc123.W/"abc123" (weak) when the response is semantically equivalent but not byte-for-byte identical (e.g., different whitespace). Use "abc123" (strong) for byte-identical responses.updatedAt timestamp) instead of serializing the full response body.res.status(304).end(), not res.status(304).json().// RIGHT — ETag from metadata, no need to serialize the full response
router.get("/api/products/:id", (req, res) => {
const meta = db.prepare("SELECT updated_at FROM products WHERE id = ?").get(req.params.id);
if (!meta) return res.status(404).json({ error: "Not found" });
const etag = `"${meta.updated_at}"`;
res.set('ETag', etag);
res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const product = db.prepare("SELECT * FROM products WHERE id = ?").get(req.params.id);
res.json({ data: product });
});The Vary header tells caches that the response differs based on certain request headers. Without it, a CDN might serve a gzip-compressed response to a client that only accepts br, or serve an English response to a French-speaking user.
// RIGHT — response varies by Accept-Encoding (compression)
// Most frameworks set this automatically, but verify it's there
res.set('Vary', 'Accept-Encoding');
// RIGHT — response varies by Accept header (JSON vs XML)
router.get("/api/products", (req, res) => {
res.set('Vary', 'Accept, Accept-Encoding');
res.set('Cache-Control', 'public, max-age=60');
// ...
});
// RIGHT — response varies by Authorization (different data per user)
router.get("/api/dashboard", authMiddleware, (req, res) => {
res.set('Vary', 'Authorization');
res.set('Cache-Control', 'private, no-store');
// ...
});Vary: Authorization on any endpoint where the response changes based on who is authenticated.Vary: Accept when your API supports multiple content types (JSON, XML, HTML).Vary: Accept-Encoding when serving compressed responses (most frameworks do this automatically).Vary: * — it effectively disables caching entirely. List specific headers instead.Vary alone doesn't cache or prevent caching. It tells caches to maintain separate entries per unique combination of the listed header values.import express from "express";
import crypto from "crypto";
const app = express();
// --- Static assets: long cache with content hashing ---
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
}));
// --- Middleware: set Vary header for API routes ---
app.use('/api', (req, res, next) => {
res.set('Vary', 'Accept-Encoding');
next();
});
// --- Public catalog: cacheable, ETag, stale-while-revalidate ---
router.get("/api/products", (req, res) => {
const products = getProducts();
const etag = `"${crypto.createHash('md5').update(JSON.stringify(products)).digest('hex')}"`;
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.json({ data: products });
});
// --- Single product: shorter cache, ETag ---
router.get("/api/products/:id", (req, res) => {
const product = getProduct(req.params.id);
if (!product) return res.status(404).json({ error: "Not found" });
const etag = `"${product.updatedAt}"`;
res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.json({ data: product });
});
// --- Search results: short cache, varies by query ---
router.get("/api/search", (req, res) => {
res.set('Cache-Control', 'public, max-age=30');
res.set('Vary', 'Accept-Encoding');
const results = search(req.query.q);
res.json({ data: results });
});
// --- Authenticated user profile: never cache ---
router.get("/api/me", authMiddleware, (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.set('Vary', 'Authorization');
res.json({ data: req.user });
});
// --- Order status: private, revalidate every time ---
router.get("/api/orders/:id", authMiddleware, (req, res) => {
res.set('Cache-Control', 'private, no-cache');
res.set('Vary', 'Authorization');
const order = getOrder(req.params.id, req.user.id);
res.json({ data: order });
});
// --- Admin mutation: no caching, invalidate related caches ---
router.post("/api/admin/products", authMiddleware, (req, res) => {
res.set('Cache-Control', 'private, no-store');
const product = createProduct(req.body);
// Invalidate in-memory cache if using one
productCache.invalidate();
res.status(201).json({ data: product });
});| Endpoint type | Example | Cache-Control | ETag? | Vary? |
|---|---|---|---|---|
| Static assets | /static/app.abc123.js | public, max-age=31536000, immutable | No (filename hashing) | No |
| Public catalog/list | GET /api/products | public, max-age=60, stale-while-revalidate=300 | Yes | Accept-Encoding |
| Single public resource | GET /api/products/:id | public, max-age=30, stale-while-revalidate=60 | Yes | Accept-Encoding |
| Search results | GET /api/search?q=... | public, max-age=30 | Optional | Accept-Encoding |
| Authenticated user data | GET /api/me | private, no-store | No | Authorization |
| User-specific mutable data | GET /api/orders/:id | private, no-cache | Optional | Authorization |
| Sensitive data (tokens, passwords) | GET /api/settings/api-key | private, no-store | No | Authorization |
| POST/PUT/DELETE mutations | POST /api/products | private, no-store | No | No |
| Health check | GET /health | no-cache | No | No |
Every response gets browser heuristic caching. CDNs cache with unpredictable TTLs. Stale data is served silently.
Fix: Set Cache-Control on every endpoint, even if the value is no-store.
no-cache when you mean no-storeno-cache still stores the response on disk. For sensitive data, this means user tokens or personal data sitting in the browser's disk cache.
Fix: Use no-store for any endpoint returning authenticated or sensitive data.
private on authenticated endpointsWithout private, shared caches (CDNs, proxies) may cache user-specific responses and serve User A's data to User B.
Fix: Always set private on endpoints that require authentication or return per-user data.
Vary header on content-negotiated responsesA CDN caches the JSON response and serves it to a client requesting XML.
Fix: Set Vary: Accept when your API supports multiple content types.
Setting the ETag header but never checking If-None-Match means clients send ETags for nothing — the server always sends the full response.
Fix: Always check req.headers['if-none-match'] and return 304 when matched.
Setting max-age=1y on /app.js means users get stale code for a year after deployment.
Fix: Use content-hashed filenames (app.abc123.js) so the URL changes when the content changes.
Rule: If you are building a GET endpoint that returns data, set Cache-Control headers. No exceptions. Do not wait for the task to mention caching, performance, or headers — add them proactively.
public, max-age=60, stale-while-revalidate=300 + ETagprivate, no-store + Vary: Authorizationprivate, no-cache + Vary: Authorizationpublic, max-age=31536000, immutable + filename hashingprivate, no-storeCache-Control headerpublic, max-age=N, stale-while-revalidate=Mprivate, no-store and Vary: Authorizationno-store, NOT no-cacheETag and handle If-None-Match with 304"abc123"res.status(304).end())immutable + content-hashed filenamesVary header set when response differs by Accept, Accept-Encoding, or Authorizationno-storeVary: * (kills caching entirely)