Back to Blog
HTTPCachingPerformanceCDN

HTTP Caching Strategies

Implement effective HTTP caching. From cache headers to CDN strategies to cache invalidation patterns.

B
Bootspring Team
Engineering
January 7, 2022
5 min read

Effective caching reduces server load and improves performance. Here's how to implement HTTP caching correctly.

Cache-Control Header#

1import express from 'express'; 2 3const app = express(); 4 5// Static assets - long cache with versioning 6app.use('/static', express.static('public', { 7 maxAge: '1y', 8 immutable: true, 9 setHeaders: (res) => { 10 res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); 11 }, 12})); 13 14// API responses - short cache 15app.get('/api/products', (req, res) => { 16 res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300'); 17 res.json(products); 18}); 19 20// Private user data - no shared cache 21app.get('/api/user/profile', (req, res) => { 22 res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); 23 res.json(userProfile); 24}); 25 26// Never cache 27app.get('/api/auth/token', (req, res) => { 28 res.setHeader('Cache-Control', 'no-store'); 29 res.json({ token }); 30}); 31 32// Cache-Control directives: 33// public - Can be cached by CDN/proxy 34// private - Only browser cache 35// max-age - Seconds until stale 36// s-maxage - CDN-specific max-age 37// no-cache - Must revalidate before using 38// no-store - Don't cache at all 39// immutable - Never changes 40// must-revalidate - Must check when stale 41// stale-while-revalidate - Serve stale while fetching fresh

ETag and Conditional Requests#

1import crypto from 'crypto'; 2 3// Generate ETag from content 4function generateETag(content: string | Buffer): string { 5 return `"${crypto.createHash('md5').update(content).digest('hex')}"`; 6} 7 8// Middleware for ETag handling 9function etag() { 10 return (req: Request, res: Response, next: NextFunction) => { 11 const originalJson = res.json.bind(res); 12 13 res.json = function (body: any) { 14 const content = JSON.stringify(body); 15 const tag = generateETag(content); 16 17 res.setHeader('ETag', tag); 18 19 // Check If-None-Match header 20 const ifNoneMatch = req.get('If-None-Match'); 21 if (ifNoneMatch === tag) { 22 return res.status(304).end(); 23 } 24 25 return originalJson(body); 26 }; 27 28 next(); 29 }; 30} 31 32app.use(etag()); 33 34// With Last-Modified 35app.get('/api/article/:id', async (req, res) => { 36 const article = await getArticle(req.params.id); 37 38 res.setHeader('Last-Modified', article.updatedAt.toUTCString()); 39 40 // Check If-Modified-Since 41 const ifModifiedSince = req.get('If-Modified-Since'); 42 if (ifModifiedSince) { 43 const lastModified = new Date(article.updatedAt); 44 const ifModifiedDate = new Date(ifModifiedSince); 45 46 if (lastModified <= ifModifiedDate) { 47 return res.status(304).end(); 48 } 49 } 50 51 res.json(article); 52});

CDN Configuration#

1// Vercel/Next.js headers 2// next.config.js 3module.exports = { 4 async headers() { 5 return [ 6 { 7 source: '/static/:path*', 8 headers: [ 9 { 10 key: 'Cache-Control', 11 value: 'public, max-age=31536000, immutable', 12 }, 13 ], 14 }, 15 { 16 source: '/api/:path*', 17 headers: [ 18 { 19 key: 'Cache-Control', 20 value: 'public, s-maxage=60, stale-while-revalidate=300', 21 }, 22 ], 23 }, 24 ]; 25 }, 26}; 27 28// Cloudflare Workers 29export default { 30 async fetch(request: Request) { 31 const url = new URL(request.url); 32 33 // Static assets 34 if (url.pathname.startsWith('/static/')) { 35 const response = await fetch(request); 36 const newResponse = new Response(response.body, response); 37 newResponse.headers.set( 38 'Cache-Control', 39 'public, max-age=31536000, immutable' 40 ); 41 newResponse.headers.set('CDN-Cache-Control', 'max-age=31536000'); 42 return newResponse; 43 } 44 45 // API with cache 46 if (url.pathname.startsWith('/api/')) { 47 const cacheKey = new Request(url.toString(), request); 48 const cache = caches.default; 49 50 let response = await cache.match(cacheKey); 51 52 if (!response) { 53 response = await fetch(request); 54 response = new Response(response.body, response); 55 response.headers.set('Cache-Control', 's-maxage=60'); 56 57 // Cache in edge 58 await cache.put(cacheKey, response.clone()); 59 } 60 61 return response; 62 } 63 64 return fetch(request); 65 }, 66};

Cache Invalidation#

1// Versioned URLs 2function assetUrl(path: string): string { 3 const hash = getFileHash(path); 4 return `/static/${hash}/${path}`; 5} 6 7// Or query string (less preferred) 8function versionedUrl(path: string, version: string): string { 9 return `${path}?v=${version}`; 10} 11 12// Purge CDN cache 13async function purgeCache(urls: string[]) { 14 // Cloudflare 15 await fetch( 16 `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`, 17 { 18 method: 'POST', 19 headers: { 20 'Authorization': `Bearer ${API_TOKEN}`, 21 'Content-Type': 'application/json', 22 }, 23 body: JSON.stringify({ files: urls }), 24 } 25 ); 26} 27 28// Cache tags for grouped invalidation 29app.get('/api/product/:id', (req, res) => { 30 res.setHeader('Cache-Tag', `product-${req.params.id}, products`); 31 res.setHeader('Cache-Control', 'public, s-maxage=3600'); 32 res.json(product); 33}); 34 35// Purge by tag 36async function purgeByTag(tag: string) { 37 await fetch( 38 `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`, 39 { 40 method: 'POST', 41 headers: { 42 'Authorization': `Bearer ${API_TOKEN}`, 43 'Content-Type': 'application/json', 44 }, 45 body: JSON.stringify({ tags: [tag] }), 46 } 47 ); 48}

Vary Header#

1// Cache varies by Accept-Encoding 2app.get('/api/data', (req, res) => { 3 res.setHeader('Vary', 'Accept-Encoding'); 4 res.json(data); 5}); 6 7// Cache varies by multiple headers 8app.get('/api/content', (req, res) => { 9 res.setHeader('Vary', 'Accept-Language, Accept-Encoding'); 10 res.json(localizedContent); 11}); 12 13// Cache varies by cookie (careful - can bust cache) 14app.get('/api/personalized', (req, res) => { 15 res.setHeader('Vary', 'Cookie'); 16 res.setHeader('Cache-Control', 'private, max-age=60'); 17 res.json(personalizedContent); 18}); 19 20// Best practice: separate cached and personalized content 21// Don't: /api/page (returns different content based on auth) 22// Do: /api/page/public + /api/page/user (separate endpoints)

Service Worker Caching#

1// sw.js 2const CACHE_NAME = 'v1'; 3 4const STATIC_ASSETS = [ 5 '/', 6 '/styles.css', 7 '/app.js', 8 '/images/logo.png', 9]; 10 11// Cache on install 12self.addEventListener('install', (event) => { 13 event.waitUntil( 14 caches.open(CACHE_NAME).then((cache) => { 15 return cache.addAll(STATIC_ASSETS); 16 }) 17 ); 18}); 19 20// Network first, fallback to cache 21self.addEventListener('fetch', (event) => { 22 if (event.request.url.includes('/api/')) { 23 event.respondWith( 24 fetch(event.request) 25 .then((response) => { 26 const clone = response.clone(); 27 caches.open(CACHE_NAME).then((cache) => { 28 cache.put(event.request, clone); 29 }); 30 return response; 31 }) 32 .catch(() => caches.match(event.request)) 33 ); 34 return; 35 } 36 37 // Cache first for static assets 38 event.respondWith( 39 caches.match(event.request).then((cached) => { 40 return cached || fetch(event.request); 41 }) 42 ); 43}); 44 45// Stale-while-revalidate 46self.addEventListener('fetch', (event) => { 47 event.respondWith( 48 caches.open(CACHE_NAME).then(async (cache) => { 49 const cached = await cache.match(event.request); 50 51 const fetchPromise = fetch(event.request).then((response) => { 52 cache.put(event.request, response.clone()); 53 return response; 54 }); 55 56 return cached || fetchPromise; 57 }) 58 ); 59});

Cache Debugging#

1// Add debug headers 2app.use((req, res, next) => { 3 res.on('finish', () => { 4 console.log({ 5 url: req.url, 6 cacheControl: res.getHeader('Cache-Control'), 7 etag: res.getHeader('ETag'), 8 vary: res.getHeader('Vary'), 9 }); 10 }); 11 next(); 12}); 13 14// Check cache status (CDN-specific header) 15// CF-Cache-Status: HIT, MISS, EXPIRED, BYPASS 16// x-vercel-cache: HIT, MISS, STALE 17 18// Browser DevTools 19// Network tab shows cache status 20// Disable cache checkbox for testing

Best Practices#

Headers: ✓ Use Cache-Control over Expires ✓ Set appropriate max-age values ✓ Use immutable for versioned assets ✓ Use stale-while-revalidate for API Strategy: ✓ Version static assets in URL ✓ Use ETags for dynamic content ✓ Separate public and private content ✓ Plan cache invalidation CDN: ✓ Use s-maxage for CDN-specific caching ✓ Implement cache tags ✓ Set up purge automation ✓ Monitor cache hit rates

Conclusion#

Effective HTTP caching balances freshness and performance. Use long cache times with versioned URLs for static assets, short TTLs with stale-while-revalidate for APIs, and plan your cache invalidation strategy from the start. Monitor cache hit rates to ensure your strategy is working.

Share this article

Help spread the word about Bootspring