正在加载,请稍候…

HTTP Caching Explained: Cache-Control, ETags, and How Browsers Cache

Understand HTTP caching headers — Cache-Control, ETag, Last-Modified, Vary — and how to configure them correctly to speed up your web app.

Why HTTP Caching Matters

HTTP caching is one of the highest-leverage performance optimizations available. A properly cached resource requires zero server processing, zero database queries, and zero bytes transferred — it's served instantly from the browser or CDN.

Done wrong, caching causes users to see stale content for hours or days after a deployment. This guide covers every important header and how they interact.

How Browser Caching Works

When a browser receives a response, it decides whether to cache it based on headers. On a subsequent request, it checks the cache first:

  1. Fresh cache hit: Use cached response directly — no network request
  2. Stale cache (revalidation needed): Send a conditional request to the server
  3. No cache / expired: Fetch the full response from the server

Cache-Control: The Primary Header

Cache-Control is the primary caching directive. It replaces the older Pragma and Expires headers.

Common Directives

Cache-Control: max-age=3600

Response can be used for 3600 seconds (1 hour) without revalidation.

Cache-Control: no-cache

Misleading name. Does NOT mean "don't cache." It means "cache it, but always revalidate before using." The browser stores the response and sends a conditional request (with ETag or Last-Modified) every time before using it.

Cache-Control: no-store

Truly never cache. No copy stored anywhere. Use for responses containing sensitive user data.

Cache-Control: public

Response can be stored by any cache (browser, CDN, proxy). Default for responses without credentials.

Cache-Control: private

Only the end user's browser should cache this. Prevents CDNs from caching user-specific responses.

Cache-Control: immutable

Tell the browser this resource will NEVER change. Skip revalidation even when the user manually refreshes. Use only for content-hashed assets.

Cache-Control: stale-while-revalidate=60

Serve stale content for up to 60 seconds while fetching an update in the background. Great for APIs where slightly stale data is acceptable.

Cache-Control: stale-if-error=86400

If the revalidation request fails (server error, network timeout), use stale content for up to 86400 seconds. Improves resilience.

Combining Directives

Cache-Control: public, max-age=31536000, immutable

For content-hashed static assets (JS, CSS with hash in filename): cache forever, everywhere, skip revalidation.

Cache-Control: private, no-cache

For user-specific HTML pages: browser caches but revalidates every time.

Cache-Control: no-store

For bank statements, medical records, sensitive downloads: never cache.

ETags: Efficient Revalidation

An ETag (Entity Tag) is a version identifier for a resource — typically a hash of the content or a version number.

Flow:

  1. Server sends: ETag: "abc123"
  2. Browser caches the response
  3. Next request, browser sends: If-None-Match: "abc123"
  4. If unchanged: server returns 304 Not Modified (empty body, very fast)
  5. If changed: server returns 200 OK with new content and new ETag
// Express — ETag middleware (built-in, enabled by default)
app.use(express.static('public'));  // serves with ETags automatically

// Manual ETag
const crypto = require('crypto');

app.get('/api/data', (req, res) => {
  const data = getDataFromDB();
  const etag = '"' + crypto.createHash('md5').update(JSON.stringify(data)).digest('hex') + '"';

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

  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'private, no-cache');  // revalidate every time
  res.json(data);
});

Strong vs Weak ETags

Strong ETag ("abc123"): byte-for-byte identical content. Cannot be used if response varies by content-encoding.

Weak ETag (W/"abc123"): semantically equivalent but may differ in minor ways (e.g., gzipped vs uncompressed). More flexible.

Last-Modified: The Legacy Approach

Before ETags, Last-Modified was used for revalidation.

Response: Last-Modified: Mon, 26 May 2026 10:00:00 GMT
Request:  If-Modified-Since: Mon, 26 May 2026 10:00:00 GMT
Response: 304 Not Modified

ETags are preferred because timestamps have 1-second resolution (files updated within the same second cause issues) and don't capture content changes that don't update the filesystem timestamp.

Many servers send both — ETags take precedence.

The Vary Header

Vary tells caches that different request headers produce different responses. If a cache stores one version, it can only use it for requests with matching header values.

Vary: Accept-Encoding

Cache separate versions for gzip, br, and identity-encoded clients.

Vary: Accept-Language

Cache separate versions for each language. Useful for internationalized content.

Vary: Authorization

⚠️ Don't do this. It creates a separate cache entry for every different Authorization value — effectively disabling caching.

Caching Strategy by Resource Type

Static Assets with Hash in Filename

main.abc123.js, styles.def456.css
Cache-Control: public, max-age=31536000, immutable

Cache for 1 year, CDN-cacheable, never revalidate. When content changes, the hash in the filename changes, so the browser always gets the new file. This is cache busting.

HTML Pages

Cache-Control: no-cache

Always revalidate. HTML pages reference the hashed assets — keeping HTML fresh ensures users get the latest asset filenames.

API Responses (User-specific)

Cache-Control: private, no-cache

With ETag: browser caches and revalidates, saving bandwidth if data hasn't changed.

API Responses (Public, infrequent updates)

Cache-Control: public, max-age=300, stale-while-revalidate=30

Cache 5 minutes, serve stale for 30 more seconds while refreshing in background.

Sensitive Data

Cache-Control: no-store

Banking, healthcare, PII — never store a copy.

Configuring Cache Headers

Nginx

# Long cache for hashed assets
location ~* .(js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Short cache for HTML
location ~* .html$ {
    add_header Cache-Control "no-cache";
}

# API responses — no CDN cache, browser revalidates
location /api/ {
    add_header Cache-Control "private, no-cache";
    add_header Vary "Authorization";
}

Express

const express = require('express');
const path = require('path');
const app = express();

// Static assets with hash — immutable cache
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), {
  maxAge: '1y',
  immutable: true,
}));

// HTML pages — always revalidate
app.use(express.static(path.join(__dirname, 'public'), {
  setHeaders: (res, filePath) => {
    if (filePath.endsWith('.html')) {
      res.setHeader('Cache-Control', 'no-cache');
    }
  },
}));

// API routes — private, revalidate
app.get('/api/*', (req, res, next) => {
  res.setHeader('Cache-Control', 'private, no-cache');
  next();
});

Cache Busting Strategies

Filename hashing (recommended): Include a content hash in the filename. Vite, webpack, and most bundlers do this automatically.

Before: /assets/main.js
After:  /assets/main.Bx9k2Ar7.js  ← hash changes when content changes

Query string versioning: Append a version parameter. Less effective — some caches ignore query strings.

<script src="/assets/main.js?v=2.1.3"></script>

URL versioning: Include a version in the path. Reliable but requires updating HTML on every release.

/v2/assets/main.js

Quick Reference

Resource Type Recommended Cache-Control
Content-hashed JS/CSS public, max-age=31536000, immutable
Images/fonts (hashed) public, max-age=31536000, immutable
HTML pages no-cache
API (public data) public, max-age=300, stale-while-revalidate=30
API (user data) private, no-cache
Sensitive data no-store

→ Use the HTTP Status Codes reference to understand 304 Not Modified and other caching-related responses.