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 freshETag 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 testingBest 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.