X Xerobit

HTTP Cache Headers — Cache-Control, ETag, and Last-Modified Explained

Master HTTP caching headers: Cache-Control directives (max-age, no-cache, immutable), ETag for conditional requests, and Last-Modified. Learn how to cache static assets forever...

Mian Ali Khalid · · 5 min read
Use the tool
HTTP Status Codes
Full HTTP status code reference with explanations and when to use each.
Open HTTP Status Codes →

HTTP caching eliminates redundant requests. A correctly cached website can serve returning visitors with zero network requests. A miscached one shows stale content for days.

Look up HTTP status codes with the HTTP Status Codes reference.

How HTTP caching works

First request:
  Client → GET /style.css
  Server → 200 OK, Cache-Control: max-age=31536000
  Cache stores: style.css + expiration date

Second request (within max-age):
  Client uses cache → no network request

After expiration:
  Client → GET /style.css, If-None-Match: "abc123"
  Server → 304 Not Modified (file unchanged, use cache)
  OR
  Server → 200 OK (file changed, here's the new one)

Cache-Control directives

# max-age: seconds until cache expires
Cache-Control: max-age=86400         # 1 day
Cache-Control: max-age=31536000      # 1 year

# no-cache: must revalidate before using cache
Cache-Control: no-cache              # Revalidate every request (304 if unchanged)

# no-store: never cache
Cache-Control: no-store              # Sensitive data (health records, banking)

# public: CDNs and proxies can cache
Cache-Control: public, max-age=86400

# private: only the browser caches (not CDNs)
Cache-Control: private, max-age=3600  # Personalized content

# immutable: file will NEVER change, skip revalidation
Cache-Control: public, max-age=31536000, immutable

# stale-while-revalidate: serve stale while fetching new in background
Cache-Control: max-age=60, stale-while-revalidate=86400

ETag and conditional requests

ETag is a fingerprint of the resource content. Browsers send it back with If-None-Match to check if the file changed:

# Server sends ETag with response:
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache

# Browser sends ETag on next request:
GET /api/data HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# If unchanged:
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# No body — browser uses its cached copy

# If changed:
HTTP/1.1 200 OK
ETag: "new-hash-value"
Content-Type: application/json
{ ...new data... }

ETags are ideal for API responses that change infrequently but need to be fresh when they do.

Last-Modified

Older alternative to ETag, using timestamps:

# Server sends Last-Modified:
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
Cache-Control: no-cache

# Browser sends If-Modified-Since on next request:
GET /image.jpg HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT

# If unchanged:
HTTP/1.1 304 Not Modified

Prefer ETags over Last-Modified — timestamps have 1-second resolution and can cause issues when files are regenerated without content changes.

Strategy: static assets vs HTML

The correct strategy is different for static assets and HTML pages:

# Static assets (JS, CSS, images) — hash in filename, cache forever:
# /static/app.3f8c2d1a.js
Cache-Control: public, max-age=31536000, immutable

# HTML pages — never cache (always fetch fresh):
# /index.html
Cache-Control: no-cache
# Or:
Cache-Control: no-store

Why? Build tools (webpack, Vite, esbuild) add content hashes to filenames. The HTML changes to reference the new hashed filename. So:

  • HTML: always fresh, but small and fast to fetch
  • Assets: cached forever, never re-downloaded unless HTML references a new hash

Express: set cache headers

import express from 'express';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';

const app = express();

// Static assets: cache 1 year
app.use('/static', express.static('public/static', {
  maxAge: '1y',
  immutable: true,
}));

// HTML: no cache
app.get('/', (req, res) => {
  res.set('Cache-Control', 'no-cache');
  res.sendFile('public/index.html');
});

// API: ETag-based caching
app.get('/api/config', (req, res) => {
  const data = { version: '1.2.3', features: ['a', 'b'] };
  const json = JSON.stringify(data);
  const etag = createHash('md5').update(json).digest('hex');
  
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }
  
  res.set({
    'ETag': etag,
    'Cache-Control': 'no-cache',
  });
  res.json(data);
});

nginx cache header configuration

server {
  # HTML — no cache
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-store, no-cache, must-revalidate";
  }

  # Hashed assets — cache 1 year
  location ~* \.(js|css|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  # Images — cache 30 days
  location ~* \.(jpg|jpeg|png|gif|svg|ico|webp)$ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000";
  }

  # API — ETag (nginx generates automatically for static files)
  location /api/ {
    etag on;
    expires -1;
    add_header Cache-Control "no-cache";
  }
}

Vary header: cache per Accept-Encoding

# Server compresses with gzip:
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
Cache-Control: public, max-age=86400

# The Vary header tells CDNs to cache separate copies per encoding.
# Without Vary: CDN might serve gzip to a client that doesn't support it.

stale-while-revalidate: smooth cache updates

# Serve immediately from cache, revalidate in background
Cache-Control: max-age=60, stale-while-revalidate=3600

# For 60s: serve from cache, no revalidation
# 60s–3660s: serve stale from cache, fetch new in background
# After 3660s: must wait for revalidation before serving

This pattern keeps fast page loads while ensuring content updates.


Related posts

Related tool

HTTP Status Codes

Full HTTP status code reference with explanations and when to use each.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.