HTTP Caching: The Headers, the Strategies, and Why Your Site Is Slower Than It Needs to Be
Caching is the single biggest performance lever most sites miss. Learn the layers (browser, CDN, reverse proxy), the headers that drive each, and the patterns that make your site feel instant.
Caching is the cheapest way to make a site faster. Done right, it can take your time-to-meaningful-paint from 1.5 seconds to 50 milliseconds for return visitors and reduce your origin server load by 90%+. Done wrong, it serves stale content for hours or fails to cache at all.
The cache layers
A request typically passes through several caches:
- Browser cache: the user's local cache. Fastest but private to the user.
- Service Worker cache: programmable browser-side cache. Powers offline-first apps.
- CDN edge cache: Cloudflare, Fastly, CloudFront. Shared across many users.
- Reverse proxy cache: Varnish, nginx, Cloudflare workers. In front of your origin.
- Application cache: Redis, Memcached. In front of your database.
- Database cache: query plan cache, buffer pool.
Most discussion of "HTTP caching" targets layers 1–4. Each has slightly different rules.
The Cache-Control header in detail
The single most important caching header. Common combinations:
Cache-Control: no-store
Don't cache anywhere. Ever. For sensitive responses (account pages, banking).
Cache-Control: no-cache
Cache, but always revalidate before using. The cache must contact origin (or another cache) to confirm the response is still valid. Despite the name, this does cache.
Cache-Control: public, max-age=31536000, immutable
The gold-standard for static assets. Cacheable by anyone, valid for a year, never check for updates. Use only with hashed filenames (app.a1b2c3.js) so changing the content changes the URL.
Cache-Control: private, max-age=600
Cacheable only by browsers (not CDNs/proxies). Useful for personalized but cacheable content like logged-in dashboards.
Cache-Control: stale-while-revalidate=86400
Serve stale content immediately while revalidating in the background. Gives instant response with eventual consistency. Powers content that's "fresh enough".
ETag vs Last-Modified
Both enable cache validation:
- Last-Modified + If-Modified-Since: based on timestamps. Coarse (1-second resolution). Easy to compute from filesystem.
- ETag + If-None-Match: opaque identifier (often a hash of content). More precise. Better for content that changes within the same second.
Modern best practice: use ETag, generated from a content hash. ETag: "a1b2c3" tells the cache to send If-None-Match: "a1b2c3" on revalidation. Server returns 304 (no body) if unchanged or 200 (full body) if changed.
The classic three-tier asset strategy
Tier 1: Hashed static assets — cache forever
JavaScript bundles, CSS files, images that have a content hash in the filename:
# Response for /js/app.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2c3d4"Browsers and CDNs cache for a year. When you ship new JavaScript, the hash changes, the URL changes, the browser fetches fresh content.
Tier 2: Non-hashed but stable assets — cache with revalidation
Logo SVG, font files, well-known image paths:
Cache-Control: public, max-age=86400, stale-while-revalidate=604800Cache for a day, allow stale-revalidate for a week. Browser returns instantly from cache; updates happen in the background.
Tier 3: HTML pages — short cache or revalidate
HTML is your site's entry point and must be fresh:
Cache-Control: public, max-age=0, must-revalidate ETag: "page-version-hash"Browser/CDN re-checks every navigation but most of the time gets a 304 (no body returned, ~50 byte response). Significant savings vs full HTML transfer.
CDN-specific patterns
Two-tier caching: edge vs browser
CDNs let you cache aggressively at the edge (10 min) and lightly in browsers (1 min):
Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=600
# Or Cloudflare:
Cache-Control: public, max-age=60, s-maxage=600The browser refreshes its cache from CDN every minute (cheap); the CDN refreshes from origin every 10 minutes (also cheap). Origin sees minimal load.
Purging and tags
Modern CDNs (Cloudflare, Fastly) support cache tags. Tag responses by article ID, then purge that tag when the article changes:
Cache-Tag: article-123, articles, latestNow editing article 123 purges its specific cache entries plus any pages tagged "latest."
API caching
APIs are often considered uncacheable, but most reads benefit from caching:
- GET requests: safe to cache by definition. Use
Cache-Control: private, max-age=60for user-specific responses. - Conditional requests: use ETag and If-None-Match. Returns 304 (no body) when nothing's changed.
- POST, PUT, DELETE: not cacheable. But caches must invalidate related GETs after mutations.
The cache-invalidation problem
Service Workers: the programmable cache
Service Workers run JavaScript that intercepts every network request:
// Service Worker
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});Patterns:
- Cache-first: use cache; fall back to network. Best for assets.
- Network-first: try network; fall back to cache if offline. Best for HTML.
- Stale-while-revalidate: serve cache immediately, update cache in background.
Workbox (Google's service-worker library) implements these patterns ergonomically.
Common caching mistakes
- Caching HTML pages too long. Users see stale content for hours.
- Not caching JavaScript bundles. Every page load re-downloads megabytes.
- Forgetting Vary headers. Cache poisoning between user populations.
- Cache-Control: no-cache as "don't cache." It does cache; you wanted no-store.
- Setting only Expires, not Cache-Control. Expires is legacy; Cache-Control wins where both are present.
- No cache for images. Large bandwidth hit. Always Cache-Control for images.
- Caching authenticated responses publicly. Personalized content cached at CDN, served to wrong users.
- Forgetting to invalidate after deploys. Old assets serve, app breaks.
Measuring cache effectiveness
Key metrics:
- Cache hit rate: % of requests served from cache. Target 80%+ for static assets.
- Origin offload: % of bandwidth or requests not reaching your origin. Target 90%+.
- Time to first byte (TTFB): drops dramatically with proper caching.
Browser DevTools "Network" tab shows cache status per resource. Cloudflare and Fastly dashboards show hit rates by status code and resource type.
Key Takeaways
- Three-tier asset strategy: hashed assets cache forever, stable assets cache with revalidation, HTML revalidates every navigation.
- Cache-Control: immutable is critical for hashed JS/CSS — browsers skip revalidation entirely.
- no-cache means "cache but revalidate." Use no-store when you want to prevent caching.
- ETags are more precise than Last-Modified. Generate ETags from content hashes for best results.
- Cache-tag-based purging makes invalidation tractable. Avoid time-based-only caching where possible.