HTTP Caching Headers: Cache-Control Explained

TL;DR

Cache-Control headers tell browsers and CDNs how long to store responses. Use max-age for static assets, no-cache for HTML, private for user data, immutable for versioned files. ETags enable conditional requests that skip downloading unchanged content.

I added two lines to our Nginx config and cut API server load by 60%. No code changes, no new infrastructure—just telling browsers and CDNs what they could cache and for how long. HTTP caching is the highest-leverage performance improvement most apps aren't fully using.

How Caching Works

Without caching:
Browser → Request → Server → Database → Response (200ms)
Browser → Request → Server → Database → Response (200ms)
Browser → Request → Server → Database → Response (200ms)

With caching:
Browser → Request → Server → Database → Response (200ms)
Browser → Cache hit → Response (0ms)
Browser → Cache hit → Response (0ms)

The browser (or CDN) stores the response. Subsequent requests never reach your server.

Cache-Control Directives

Cache-Control: max-age=3600, public

max-age=N — Cache for N seconds

Cache-Control: max-age=86400   # Cache for 1 day
Cache-Control: max-age=0       # Don't cache

public — CDNs and shared caches can store this

Cache-Control: public, max-age=3600   # CDN can cache

private — Only the browser can cache, not CDNs

Cache-Control: private, max-age=3600  # User-specific data

no-cache — Revalidate every time (can still use cached copy if unchanged)

Cache-Control: no-cache   # Check with server before using cache

no-store — Never store this response

Cache-Control: no-store   # Sensitive data, never cache

immutable — Content will never change, skip revalidation entirely

Cache-Control: public, max-age=31536000, immutable  # Versioned static files

Static Assets: Cache Aggressively

# BAD - no cache headers, every page reload re-downloads assets
location /static/ {
    root /app/public;
}

# GOOD - long cache with versioned filenames
location /static/ {
    root /app/public;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

The trick: use content hashes in filenames. When the file changes, the filename changes, so the old cache is never used:

/static/app.js         ← BAD: changing content doesn't bust cache
/static/app.abc123.js  ← GOOD: new hash = new URL = fresh download

Most build tools do this automatically:

// webpack.config.js
module.exports = {
    output: {
        filename: '[name].[contenthash].js',  // app.abc123.js
    }
};

// vite.config.js - content hashes enabled by default in production

HTML: Cache Short or Not at All

# HTML pages: short or no cache
# You want users to always get the latest version

# Option 1: Always revalidate (recommended)
Cache-Control: no-cache

# Option 2: Very short cache
Cache-Control: public, max-age=60

# Option 3: CDN caches, browser doesn't
Cache-Control: public, s-maxage=3600, max-age=0

API Responses: It Depends

// Express - set cache headers based on data sensitivity

// BAD - no cache headers on any API response
app.get('/api/products', async (req, res) => {
    const products = await getProducts();
    res.json(products);
});

// GOOD - public data CDN can cache
app.get('/api/products', async (req, res) => {
    const products = await getProducts();
    res.set('Cache-Control', 'public, max-age=300');  // 5 minutes
    res.json(products);
});

// User-specific data: only this browser caches it
app.get('/api/user/profile', authenticate, async (req, res) => {
    const profile = await getUserProfile(req.user.id);
    res.set('Cache-Control', 'private, max-age=300');
    res.json(profile);
});

// Real-time or sensitive data: never cache
app.get('/api/prices/live', async (req, res) => {
    const prices = await getLivePrices();
    res.set('Cache-Control', 'no-store');
    res.json(prices);
});

ETags: Smart Revalidation

ETag lets the browser check "has this changed?" without re-downloading:

First request:
Client  →  GET /api/posts/123
Server  ←  200 OK
           ETag: "abc123"
           Body: { title: "My Post", ... }  (full response)

Second request:
Client  →  GET /api/posts/123
           If-None-Match: "abc123"

Server  ←  304 Not Modified   # Unchanged: no body, browser uses cache
       OR  200 OK              # Changed: full response with new ETag
           ETag: "def456"
const crypto = require('crypto');

app.get('/api/posts/:id', async (req, res) => {
    const post = await getPost(req.params.id);

    // Generate ETag from content hash
    const etag = '"' + crypto
        .createHash('md5')
        .update(JSON.stringify(post))
        .digest('hex') + '"';

    if (req.headers['if-none-match'] === etag) {
        return res.status(304).end();
    }

    res.set('ETag', etag);
    res.set('Cache-Control', 'no-cache');  // Revalidate, but use cache if unchanged
    res.json(post);
});

// Express enables ETags by default for res.json/res.send
app.set('etag', 'strong');

Last-Modified: Time-Based Revalidation

app.get('/api/posts/:id', async (req, res) => {
    const post = await getPost(req.params.id);
    const lastModified = post.updatedAt.toUTCString();

    const ifModifiedSince = req.headers['if-modified-since'];
    if (ifModifiedSince && new Date(ifModifiedSince) >= post.updatedAt) {
        return res.status(304).end();
    }

    res.set('Last-Modified', lastModified);
    res.set('Cache-Control', 'no-cache');
    res.json(post);
});

CDN Caching: s-maxage

CDNs (Cloudflare, Fastly, CloudFront) have their own cache separate from the browser:

# Different cache times for browser vs CDN
Cache-Control: public, max-age=60, s-maxage=3600
# Browser: 1 minute
# CDN: 1 hour
// Product page: short browser cache, long CDN cache
// When data changes, purge CDN cache via API
res.set('Cache-Control', 'public, max-age=60, s-maxage=3600');

// User dashboard: never let CDN cache
res.set('Cache-Control', 'private, max-age=300');

Vary Header: Cache Per Variant

# Store different cached versions based on request headers
Vary: Accept-Encoding   # Compressed vs uncompressed
Vary: Accept-Language   # English vs French
app.get('/api/data', (req, res) => {
    res.set('Vary', 'Accept-Encoding');
    res.set('Cache-Control', 'public, max-age=3600');

    if (req.headers['accept-encoding']?.includes('gzip')) {
        res.set('Content-Encoding', 'gzip');
        res.send(compressedData);
    } else {
        res.send(rawData);
    }
});

Quick Reference

Static assets (versioned filenames)
  Cache-Control: public, max-age=31536000, immutable

HTML pages
  Cache-Control: no-cache

API - public data
  Cache-Control: public, max-age=300

API - user-specific data
  Cache-Control: private, max-age=300

API - real-time or sensitive
  Cache-Control: no-store

CDN edge caching
  Cache-Control: public, max-age=60, s-maxage=3600

The Bottom Line

HTTP caching is free performance. Two headers on your static file server eliminates most asset requests. Proper API cache headers cut database load and reduce latency for every user.

Key points:

  • Use immutable + long max-age for versioned static files
  • Use no-cache for HTML so browsers check for updates
  • Use private for user-specific data so CDNs don't mix users
  • ETags let browsers verify cached content is still fresh
  • s-maxage controls CDN cache separately from browser cache

Check your response headers right now with curl -I https://yoursite.com/. If you don't see Cache-Control, you're leaving performance on the table.