HTTP Caching
HTTP Caching
HTTP caching is one of the most powerful and underutilized performance optimization techniques. By leveraging browser and CDN caches through proper HTTP headers, you can dramatically reduce server load and improve user experience.
Cache-Control Header
The Cache-Control header is the primary mechanism for controlling cache behavior:
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.json(products);
});
Cache-Control Directives:
public- Response can be cached by any cache (browser, CDN, proxy)private- Response can only be cached by the browser (not CDN/proxy)no-cache- Must revalidate with server before using cached responseno-store- Do not cache at all (sensitive data)max-age=N- Cache is fresh for N secondss-maxage=N- Like max-age but only for shared caches (CDN)must-revalidate- Must not use stale cache, must revalidateimmutable- Resource will never change (perfect for versioned assets)
Common Caching Strategies
// These files never change (new version = new filename)
app.use('/static', express.static('public', {
maxAge: '1y', // 1 year
immutable: true
}));
// API responses that change occasionally
app.get('/api/config', (req, res) => {
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
res.json(config);
});
// User-specific data
app.get('/api/user/profile', (req, res) => {
res.set('Cache-Control', 'private, max-age=60'); // 1 minute, browser only
res.json(userProfile);
});
// Sensitive data
app.get('/api/user/credit-card', (req, res) => {
res.set('Cache-Control', 'no-store'); // Never cache
res.json(sensitiveData);
});
// Dynamic content that should be revalidated
app.get('/api/news', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, must-revalidate');
res.json(news);
});
public, max-age=31536000, immutable for static assets with content-based filenames (e.g., app.a3f2b1.js). This provides maximum caching benefits.ETag (Entity Tag)
ETags enable conditional requests - the server sends a unique identifier for each version of a resource:
app.get('/api/products', async (req, res) => {
const products = await db.products.findAll();
const content = JSON.stringify(products);
// Generate ETag from content hash
const etag = crypto
.createHash('md5')
.update(content)
.digest('hex');
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
// Content hasn't changed
return res.status(304).end(); // Not Modified
}
// Content changed, send new version
res.set({
'ETag': etag,
'Cache-Control': 'public, max-age=60'
});
res.json(products);
});
Last-Modified Header
Similar to ETag but uses timestamps instead of content hashes:
const article = await db.articles.findById(req.params.id);
const lastModified = article.updated_at;
// Parse If-Modified-Since header
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince);
const serverDate = new Date(lastModified);
if (serverDate <= clientDate) {
return res.status(304).end(); // Not Modified
}
}
res.set({
'Last-Modified': lastModified.toUTCString(),
'Cache-Control': 'public, max-age=300'
});
res.json(article);
});
Vary Header
The Vary header tells caches that the response depends on certain request headers:
app.get('/api/messages', (req, res) => {
const lang = req.headers['accept-language']?.split(',')[0] || 'en';
const messages = getMessagesForLanguage(lang);
res.set({
'Cache-Control': 'public, max-age=3600',
'Vary': 'Accept-Language' // Cache separately per language
});
res.json(messages);
});
// Response varies by Accept-Encoding
app.get('/api/large-data', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=86400',
'Vary': 'Accept-Encoding' // Cache compressed and uncompressed separately
});
res.json(largeData);
});
// Multiple vary headers
app.get('/api/content', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=1800',
'Vary': 'Accept-Language, Accept-Encoding'
});
res.json(content);
});
Vary: Cookie or Vary: Authorization can make CDN caching ineffective. Use these sparingly and consider alternative approaches for personalized content.Express Middleware for HTTP Caching
Create reusable middleware for common caching patterns:
function cache(seconds, options = {}) {
return (req, res, next) => {
const { isPublic = true, mustRevalidate = false, vary } = options;
let cacheControl = isPublic ? 'public' : 'private';
cacheControl += `, max-age=${seconds}`;
if (mustRevalidate) cacheControl += ', must-revalidate';
res.set('Cache-Control', cacheControl);
if (vary) res.set('Vary', vary);
next();
};
}
// ETag middleware
function etag() {
return (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function(data) {
const content = JSON.stringify(data);
const hash = crypto
.createHash('md5')
.update(content)
.digest('hex');
if (req.headers['if-none-match'] === hash) {
return res.status(304).end();
}
res.set('ETag', hash);
return originalJson(data);
};
next();
};
}
// Usage
app.get('/api/products',
cache(300), // 5 minutes
etag(),
async (req, res) => {
const products = await db.products.findAll();
res.json(products);
}
);
app.get('/api/user/profile',
cache(60, { isPublic: false }), // 1 minute, private
async (req, res) => {
const profile = await getUserProfile(req.userId);
res.json(profile);
}
);
CDN Integration
Optimize cache headers for CDN (CloudFlare, Fastly, AWS CloudFront):
app.get('/api/static/config', (req, res) => {
res.set({
// Browsers cache for 5 minutes
// CDN caches for 1 hour
'Cache-Control': 'public, max-age=300, s-maxage=3600',
'CDN-Cache-Control': 'max-age=3600' // CloudFlare-specific
});
res.json(config);
});
// Bypass CDN for personalized content
app.get('/api/recommendations', (req, res) => {
res.set({
'Cache-Control': 'private, max-age=60',
'Surrogate-Control': 'no-store' // Tell CDN not to cache
});
res.json(recommendations);
});
Stale-While-Revalidate
Serve stale content while fetching fresh data in the background:
res.set({
// Cache for 30 seconds
// Allow serving stale for 60 seconds while revalidating
'Cache-Control': 'public, max-age=30, stale-while-revalidate=60'
});
res.json(feed);
});
stale-while-revalidate provides the best user experience - users always get instant responses while the cache updates in the background.Cache Debugging
Add debugging headers to understand cache behavior:
return (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
res.set({
'X-Cache-Status': res.statusCode === 304 ? 'HIT' : 'MISS',
'X-Response-Time': `${duration}ms`
});
});
next();
};
}
// Enable in development
if (process.env.NODE_ENV === 'development') {
app.use(cacheDebug());
}