Web

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.

12 min read

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:

  1. Browser cache: the user's local cache. Fastest but private to the user.
  2. Service Worker cache: programmable browser-side cache. Powers offline-first apps.
  3. CDN edge cache: Cloudflare, Fastly, CloudFront. Shared across many users.
  4. Reverse proxy cache: Varnish, nginx, Cloudflare workers. In front of your origin.
  5. Application cache: Redis, Memcached. In front of your database.
  6. 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=604800

Cache 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=600

The 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, latest

Now 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=60 for 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

"There are only two hard things in computer science: cache invalidation, naming things, and off-by-one errors." The hardest part of caching isn't setting it up — it's purging the right entries when content changes. Cache tags, surrogate keys, and content-addressed URLs are tools to make invalidation tractable.

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.