HTTP caching is one of those topics where everyone knows it exists, few people implement it correctly, and almost nobody talks about the failure modes.
This is the guide I wish existed when I was building my first API that needed to stay fast under load.
What HTTP caching actually is
HTTP caching isn't one mechanism. It's a family of related headers that tell clients and proxies what to do with a response. Getting them wrong means either stale data reaching users or your server hammered on every request.
The three headers you need to understand:
Cache-Control: max-age=300, stale-while-revalidate=60
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"
Last-Modified: Wed, 19 Mar 2026 12:00:00 GMT
These do very different things. Let's break them down.
Cache-Control
The most important header. It tells the browser (and any CDN/proxy in between) how long to cache the response.
// The directives you'll actually use:
res.setHeader('Cache-Control', 'public, max-age=300');
// Public = CDNs can cache this. max-age=300 = valid for 5 minutes.
res.setHeader('Cache-Control', 'private, max-age=0, no-store');
// Private = only the browser can cache (not CDNs). No-store = don't cache at all.
res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=60');
// Serve stale for 60s while revalidating in the background. The key to fast APIs.
The stale-while-revalidate directive is wildly underused. It says: "serve the cached version immediately (fast!), but kick off a background request to refresh it." Users get instant responses; data stays fresh. This pattern alone can cut your P99 latency by 60-80% on cached routes.
ETags - conditional requests
An ETag is a fingerprint for a response. If the response changes, the ETag changes. The browser sends the ETag back on subsequent requests, and your server can respond with 304 Not Modified if nothing changed - skipping the response body entirely.
const crypto = require('crypto');
function generateETag(data) {
return '"' + crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex') + '"';
}
app.get('/api/products', async (req, res) => {
const products = await db.getProducts();
const etag = generateETag(products);
// Check if client already has this version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // No body - saves bandwidth
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(products);
});
A 304 response has no body. For a /api/products endpoint returning 50KB of JSON, that's 50KB saved on every cache hit. At scale, this matters.
A complete caching middleware in 45 lines
Here's a reusable Express middleware that handles both ETag and Cache-Control for you:
// cache-middleware.js
const crypto = require('crypto');
/**
* HTTP caching middleware for Express APIs.
* Handles ETag validation and Cache-Control headers automatically.
*
* Usage: app.get('/api/data', cacheFor(300), handler)
* Or: app.use('/api/public', cacheFor(60, { staleWhileRevalidate: 30 }))
*/
function cacheFor(maxAgeSeconds, options = {}) {
const { staleWhileRevalidate = 0, visibility = 'public' } = options;
return (req, res, next) => {
// Capture the original json() method
const originalJson = res.json.bind(res);
res.json = function (data) {
const etag = '"' + crypto
.createHash('sha1')
.update(JSON.stringify(data))
.digest('hex').slice(0, 16) + '"';
// Check conditional request
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set caching headers
let cacheControl = `${visibility}, max-age=${maxAgeSeconds}`;
if (staleWhileRevalidate > 0) {
cacheControl += `, stale-while-revalidate=${staleWhileRevalidate}`;
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', cacheControl);
res.setHeader('Vary', 'Accept-Encoding');
return originalJson(data);
};
next();
};
}
module.exports = { cacheFor };
Use it like this:
const { cacheFor } = require('./cache-middleware');
// Cache product list for 5 minutes, serve stale for 60s while revalidating
app.get('/api/products', cacheFor(300, { staleWhileRevalidate: 60 }), async (req, res) => {
const products = await db.getProducts();
res.json(products);
});
// Cache user profile for 60 seconds, private (user-specific)
app.get('/api/profile', authenticate, cacheFor(60, { visibility: 'private' }), async (req, res) => {
const profile = await db.getUserProfile(req.user.id);
res.json(profile);
});
// Never cache sensitive endpoints
app.post('/api/payment', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
// ...
});
When to cache what
Not all endpoints should be cached the same way. Here's my mental model:
| Endpoint type | Cache-Control | Why |
|---|---|---|
| Static reference data (countries, categories) | public, max-age=86400 |
Changes rarely, same for everyone |
| Product/content lists | public, max-age=300, stale-while-revalidate=60 |
Mostly shared, tolerate 5-min staleness |
| User-specific data | private, max-age=60 |
Only browser cache, not CDN |
| Authenticated API calls | private, max-age=30 |
Short TTL, no CDN |
| Payment/checkout | no-store |
Never cache |
| Search results |
public, max-age=30 or no-cache
|
Depends on staleness tolerance |
| Webhook endpoints | no-store |
POST requests shouldn't cache |
The failure modes
Caching authenticated responses publicly. If a user-specific response leaks into a CDN cache, the next person to request that URL gets someone else's data. Always use private for authenticated responses. Never use public for anything that varies by user.
Not varying on the right headers. If your API returns different responses based on Accept-Language or Authorization, you must include those in Vary:
res.setHeader('Vary', 'Accept-Language, Accept-Encoding');
Without this, a CDN might serve a cached English response to a French user.
Stale cache after a deploy. Your API returns a 5-minute cached response. You deploy a bug fix. Users see the broken version for up to 5 minutes. Solutions:
- Use short TTLs (
max-age=60) withstale-while-revalidate - Include a version hash in your API path (
/api/v2/products) - Use ETags so clients always validate before assuming cached data is valid
Caching error responses. res.json({ error: 'Service unavailable' }) should never be cached. Check for errors before setting cache headers:
res.json = function (data) {
// Don't cache error responses
if (data && data.error) {
res.setHeader('Cache-Control', 'no-store');
return originalJson(data);
}
// ... caching logic
};
Testing your cache headers
The fastest way to verify your caching is working:
# First request - should be a cache MISS (no ETag stored yet)
curl -I "https://your-api.com/api/products"
# Look for: ETag: "abc123", Cache-Control: public, max-age=300
# Second request with the ETag - should return 304
curl -I "https://your-api.com/api/products" \
-H 'If-None-Match: "abc123"'
# Look for: HTTP/2 304 - no body returned
Chrome DevTools ? Network tab ? check the "Size" column. A "304" response shows "(from cache)" or a tiny byte size - that's confirmation it's working.
The 5-minute implementation
Add this to your Express API right now:
- Install nothing - this uses only Node built-ins
- Add
cache-middleware.jsfrom above - Apply
cacheFor(300, { staleWhileRevalidate: 60 })to your highest-traffic read endpoints - Apply
cacheFor(60, { visibility: 'private' })to user-specific endpoints - Add
no-storeto payment and auth endpoints - Test with
curl -Iand verify ETags
For a typical API serving 10,000 requests/day with 40% cache hit rate, this reduces database load by ~4,000 queries/day. For free.
I build APIs and developer tools. Currently working on SnapAPI - a web intelligence API for developers and AI pipelines.
Top comments (0)