HTTP Caching for Web Performance: A Practical Cache-Control Guide

Get HTTP caching right with production-tested Cache-Control recipes for every asset type. Covers CDN strategies, stale-while-revalidate, ETag validation, cache busting, and 103 Early Hints.

Every HTTP request that never leaves the browser cache is a request your server doesn't have to handle — and one your user doesn't have to wait for. Yet caching remains one of the most misunderstood corners of web performance. Developers either avoid it entirely (terrified of serving stale content) or slap on aggressive headers and then wonder why users keep seeing outdated pages.

I've spent years debugging caching issues, and honestly, most problems come down to a handful of misconfigurations that are easy to fix once you understand the underlying mechanics.

This guide covers everything you need to get HTTP caching right in 2026: the Cache-Control directives that actually matter, practical header recipes for every asset type, CDN-layer strategies, validation mechanics, cache busting patterns, and newer techniques like stale-while-revalidate and 103 Early Hints that are reshaping how the fastest sites deliver content.

How HTTP Caching Works: The Two-Layer Model

Before we get into directives, let's build a clear mental model. HTTP caching operates at two layers:

  • Private cache (browser cache) — stores responses for a single user. Controlled by the Cache-Control header the browser receives.
  • Shared cache (CDN / proxy cache) — stores responses at edge servers and serves them to many users. Controlled by s-maxage, Surrogate-Control, and CDN-specific headers.

When a browser requests a resource, it checks its private cache first. If the resource is fresh (still within its max-age), it's served instantly — zero network cost. If it's stale, the browser sends a conditional request using If-None-Match (ETag) or If-Modified-Since headers. The server then returns either a 304 Not Modified (no body, minimal bytes) or a full 200 OK with the updated resource.

Understanding this flow matters because every Cache-Control directive you set affects which path a request takes through this system.

Cache-Control Directives That Actually Matter

The Cache-Control header supports dozens of directives, but in practice you'll use a handful over and over. Here's each one with its real-world meaning:

max-age

Sets the freshness lifetime in seconds. During this window, the browser serves the cached copy without contacting the server at all.

Cache-Control: max-age=3600

This tells the browser: "This resource is good for one hour. Don't revalidate during that time."

s-maxage

Overrides max-age specifically for shared caches (CDNs, proxies). This lets you set a short browser cache but a longer CDN cache — which is critical for sites that need to purge CDN caches independently of browser caches.

Cache-Control: max-age=0, s-maxage=86400

The browser always revalidates, but the CDN serves from cache for up to 24 hours.

no-cache

This is probably the most misunderstood directive in all of HTTP. It does not mean "do not cache." (I know, the naming is terrible.) It means: "You can store this, but you must revalidate with the origin server before every use." The resource is cached, but the browser always sends a conditional request before serving it.

Cache-Control: no-cache

no-store

This is the true "do not cache" directive. The response must not be stored in any cache — browser or CDN. Use it for sensitive data like account pages, checkout flows, or anything containing personally identifiable information.

Cache-Control: no-store

public vs. private

The public directive allows any cache (browser, CDN, proxy) to store the response. The private directive restricts caching to the browser only — shared caches must not store it. Use private for user-specific content like personalized dashboards or authenticated API responses.

# Cacheable everywhere
Cache-Control: public, max-age=86400

# Browser only — never store on CDN
Cache-Control: private, max-age=600

immutable

Tells the browser that the resource content will never change while it's fresh. This prevents revalidation even when the user explicitly hits refresh. Facebook actually pioneered this directive after discovering that roughly 20% of their static assets were being needlessly revalidated on page refresh — adding hundreds of milliseconds of latency for no reason.

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

Browser support is solid: Firefox (since v49), Edge (since v15), and Safari all honor it. Chrome doesn't implement it directly but skips revalidation on reload for subresources by default, achieving the same effect.

must-revalidate

Once the cached response goes stale (past its max-age), the cache must not use it without first validating with the origin. This is technically the default behavior for most caches, but adding it explicitly prevents edge cases where a cache might serve stale content during network hiccups.

stale-while-revalidate

This is the directive that changed modern caching strategy. It creates a grace period after max-age expires during which the cache serves the stale response immediately while fetching an update in the background.

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

Translation: "Fresh for 10 minutes. For the next 30 seconds after that, go ahead and serve stale — but revalidate in the background." The user gets instant responses while freshness is maintained asynchronously. All major CDNs support this, including Cloudflare, Fastly, Google Cloud CDN, and Amazon CloudFront.

Practical Cache-Control Recipes by Asset Type

Stop guessing which headers to use. Here are production-tested recipes for every common asset type:

Fingerprinted static assets (JS, CSS with content hash)

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

Files like app.a1b2c3d4.js never change — when the content updates, the filename changes. Cache them for a year with immutable to prevent any revalidation. This is the gold standard.

HTML documents

Cache-Control: no-cache

HTML is the entry point that references all other assets. It must always be revalidated so users get the latest version with correct asset references. Combined with an ETag, this means the browser sends a tiny conditional request and usually gets a 304 back — fast but always fresh.

Images (non-fingerprinted)

Cache-Control: public, max-age=86400, stale-while-revalidate=3600

Cache images for 24 hours. After expiry, serve stale for up to an hour while revalidating in the background. This strikes a nice balance between freshness and performance for content images that don't change often.

API responses (public data)

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

Browser caches for 1 minute, CDN caches for 5 minutes, and stale responses get served for 30 seconds during revalidation. Ideal for product listings, blog feeds, or any public data endpoint.

Authenticated API responses

Cache-Control: private, max-age=0, must-revalidate

Never store on a CDN. The browser stores it but must revalidate on every request. This prevents personalized data from leaking through shared caches.

Sensitive pages (checkout, account)

Cache-Control: no-store

No caching whatsoever. Fetched fresh every single time.

Implementing Cache Headers: Server Configuration Examples

Here's how to apply these recipes on the most common web servers:

Nginx

server {
    # Fingerprinted static assets
    location ~* \.(js|css)$ {
        if ($uri ~* "\.[a-f0-9]{8,}\.") {
            add_header Cache-Control "public, max-age=31536000, immutable";
        }
    }

    # Images
    location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
        add_header Cache-Control "public, max-age=86400, stale-while-revalidate=3600";
    }

    # HTML documents
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
        add_header ETag "";
    }

    # Fonts
    location ~* \.(woff2|woff|ttf|otf)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Access-Control-Allow-Origin "*";
    }
}

Apache (.htaccess)

<IfModule mod_headers.c>
    # Fingerprinted static assets (JS/CSS with hash in filename)
    <FilesMatch "\.[a-f0-9]{8,}\.(js|css)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>

    # Images
    <FilesMatch "\.(png|jpg|jpeg|gif|webp|avif|svg|ico)$">
        Header set Cache-Control "public, max-age=86400, stale-while-revalidate=3600"
    </FilesMatch>

    # HTML
    <FilesMatch "\.html$">
        Header set Cache-Control "no-cache"
    </FilesMatch>

    # Fonts
    <FilesMatch "\.(woff2|woff|ttf|otf)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
</IfModule>

Express.js (Node.js)

const express = require("express");
const app = express();

// Fingerprinted static assets — cache forever
app.use("/assets", express.static("dist/assets", {
  maxAge: "1y",
  immutable: true,
  setHeaders: (res) => {
    res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  }
}));

// HTML — always revalidate
app.get("*.html", (req, res, next) => {
  res.setHeader("Cache-Control", "no-cache");
  next();
});

// API — short cache with stale-while-revalidate
app.get("/api/public/*", (req, res, next) => {
  res.setHeader("Cache-Control",
    "public, max-age=60, s-maxage=300, stale-while-revalidate=30");
  next();
});

Cache Validation: ETags and Conditional Requests

Cache-Control determines when to revalidate. ETags and Last-Modified determine how to revalidate efficiently.

How ETag validation works

  1. The server sends a response with an ETag header — a unique identifier (usually a hash) for that version of the resource.
  2. The browser stores the response along with the ETag.
  3. On the next request, the browser sends If-None-Match: "the-etag-value".
  4. If the resource hasn't changed, the server responds with 304 Not Modified (no body). The browser uses its cached version.
  5. If the resource has changed, the server sends a full 200 OK with the new content and a new ETag.

That 304 response is typically under 200 bytes — compared to potentially hundreds of kilobytes for the full resource. Over thousands of requests, the bandwidth and latency savings really add up.

ETag configuration in Nginx

# ETags are enabled by default in Nginx.
# To verify, ensure this is NOT present:
# etag off;

# For strong validation, ensure gzip does not strip ETags:
gzip_proxied any;
etag on;

Weak vs. strong ETags

A strong ETag (e.g., "33a64df5") indicates byte-for-byte identity. A weak ETag (e.g., W/"33a64df5") indicates semantic equivalence — the content is functionally the same even if not byte-identical. Weak ETags come in handy when compression or minification might produce slightly different bytes for what's essentially the same content.

CDN Caching: The Shared Cache Layer

A CDN is basically a globally distributed shared cache. When properly configured, it can handle over 90% of requests at edge servers near your users, dramatically cutting TTFB and origin server load.

Origin shielding and tiered caching

When a CDN cache miss happens at an edge server, instead of hitting your origin directly, the request first goes to a regional "shield" server. If the shield has the content cached, it responds — protecting your origin from a thundering herd of requests from every edge location at once. Cloudflare, Fastly, and AWS CloudFront all support this pattern, and it's worth enabling if you have any meaningful traffic.

Request collapsing

When multiple users request the same uncached resource simultaneously, request collapsing combines these into a single origin request. Without it, a viral piece of content could generate thousands of simultaneous origin requests as CDN caches expire globally. Not fun.

Cache key design

The cache key determines what makes a cached response unique. By default, most CDNs use the full URL including query strings. Here are the optimizations worth making:

  • Strip marketing parameters: Remove utm_source, utm_medium, fbclid, and similar tracking params from the cache key. These create unnecessary cache fragmentation — the same page ends up cached hundreds of times with different tracking suffixes.
  • Normalize query parameter order: /page?a=1&b=2 and /page?b=2&a=1 should hit the same cache entry.
  • Use the Vary header carefully: Vary: Accept-Encoding is fine for storing separate gzip and Brotli versions. But be very cautious with Vary: User-Agent — it creates a separate cache entry for every unique user agent string, which can absolutely destroy your cache hit ratio.

Cloudflare Worker example

// Cloudflare Worker to set aggressive caching for static assets
addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const url = new URL(request.url);

  // Strip tracking parameters from cache key
  const trackingParams = [
    "utm_source", "utm_medium", "utm_campaign",
    "utm_term", "utm_content", "fbclid", "gclid"
  ];
  trackingParams.forEach(p => url.searchParams.delete(p));

  const cacheKey = new Request(url.toString(), request);

  // Check CDN cache
  const cache = caches.default;
  let response = await cache.match(cacheKey);

  if (!response) {
    response = await fetch(request);
    // Clone and cache with custom TTL
    const headers = new Headers(response.headers);
    headers.set("Cache-Control", "public, s-maxage=86400, stale-while-revalidate=3600");
    response = new Response(response.body, { ...response, headers });
    event.waitUntil(cache.put(cacheKey, response.clone()));
  }

  return response;
}

103 Early Hints: Preloading During Server Think Time

HTTP 103 Early Hints is a relatively newer status code that works alongside caching to cut perceived load times. The idea is simple: when your server needs time to generate the full response (querying databases, rendering templates), it can immediately send a 103 response telling the browser to start preloading critical assets while it waits.

HTTP/1.1 103 Early Hints
Link: </styles/main.css>; rel=preload; as=style
Link: </scripts/app.js>; rel=preload; as=script
Link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
...

Real-world numbers from Akamai showed that Early Hints shifted First Contentful Paint from roughly 400ms to 300ms by starting resource loading about 90ms before the HTML arrived. Shopify saw consistent improvements on desktop too, though they noted that over-hinting (preloading too many resources) can actually hurt performance on mobile devices with limited bandwidth.

The key rule here: hint only your 2-3 most critical render-blocking resources — the main CSS file, the primary JS bundle, and maybe the primary font. Don't hint images or non-critical scripts.

Cache Busting: Safely Updating Cached Content

Here's the fundamental tension with caching: the harder you cache, the harder it is to push updates. Cache busting is the set of techniques for making sure users actually get your new content when you deploy.

Content hash fingerprinting (recommended)

Modern build tools (Vite, webpack, Rollup) automatically append a content hash to filenames:

# Before build
src/app.js
src/styles.css

# After build (Vite example)
dist/assets/app-BkG3x9Z2.js
dist/assets/styles-R4nD0mH4.css

When file content changes, the hash changes, producing a new URL. The old cached file doesn't get invalidated — it simply stops being referenced. The new URL has no cached version, so the browser fetches it fresh. This is the safest and most efficient cache busting approach because it requires zero CDN purges. If you're not using this pattern yet, it should be at the top of your list.

Vite configuration for content hashing

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    // Content hashing is enabled by default in Vite
    // Output: assets/app-[hash].js
    rollupOptions: {
      output: {
        // Customize the hash pattern if needed
        entryFileNames: "assets/[name]-[hash].js",
        chunkFileNames: "assets/[name]-[hash].js",
        assetFileNames: "assets/[name]-[hash].[ext]"
      }
    }
  }
});

Query string versioning (fallback)

If you can't rename files (legacy systems, CMS constraints), appending a version query string works as a fallback:

<link rel="stylesheet" href="/styles/main.css?v=2.4.1">
<script src="/scripts/app.js?v=2.4.1"></script>

Fair warning though — this approach is less reliable. Some CDNs and proxies ignore query strings in cache keys by default. Always verify your CDN configuration if you're going this route.

Measuring Cache Performance

You can't optimize what you can't measure. Here's how to verify your caching strategy is actually working:

Chrome DevTools

Open the Network panel and check the Size column. Resources served from cache show "(disk cache)" or "(memory cache)" instead of a byte count. The Time column shows near-zero for cached resources. Enable "Disable cache" in DevTools to test uncached behavior, then toggle it off to confirm caching kicks in.

Key headers to inspect

  • Cache-Control — verify your intended directives are present
  • Age — how many seconds the response has lived in a shared cache (CDN). If this header is present, the CDN is caching.
  • X-Cache — many CDNs (CloudFront, Fastly) add this with values like HIT, MISS, or REFRESH
  • CF-Cache-Status — Cloudflare-specific: HIT, MISS, EXPIRED, DYNAMIC
  • ETag — if present, conditional requests are working

Cache hit ratio

Your cache hit ratio is the percentage of requests served from cache versus total requests. A well-configured site should aim for above 90% on static assets. Most CDN dashboards (Cloudflare Analytics, CloudFront reports, Fastly real-time stats) show this metric prominently.

If your hit ratio is disappointingly low, the usual suspects are:

  • Too-short max-age or s-maxage values
  • Excessive Vary header variation (especially Vary: User-Agent — seriously, don't do this)
  • Query string fragmentation from tracking parameters
  • Set-Cookie on cacheable responses (many CDNs won't cache responses that include cookies)

Common Caching Mistakes and How to Fix Them

Mistake 1: Confusing no-cache with no-store

This trips up so many developers. no-cache caches the resource but revalidates every time. no-store doesn't cache at all. If you want to make absolutely sure sensitive data is never stored, use no-store. If you want fresh content but still want the bandwidth savings of conditional requests, use no-cache.

Mistake 2: Setting cookies on static asset responses

Many CDNs refuse to cache responses that include Set-Cookie headers. If your application framework adds session cookies to every response (looking at you, some older PHP setups), configure it to exclude static asset paths — or use a separate cookie-free domain for static assets.

Mistake 3: Using Vary: User-Agent

There are thousands of unique user agent strings out there. Using Vary: User-Agent creates a separate cache entry for each one, effectively nuking your cache hit ratio. If you genuinely need to serve different content for mobile vs desktop, use Vary: Sec-CH-UA-Mobile (a Client Hint with only two possible values) or handle the differentiation at the edge with a CDN worker.

Mistake 4: Long max-age on non-fingerprinted assets

Setting max-age=31536000 on a file like /app.js (no content hash in the filename) means users could be stuck with an outdated version for up to a year with no way to force an update. Only use long cache durations on files that have content hashes in their URLs.

Mistake 5: Ignoring CDN cache after deployments

After deploying new HTML that references new asset filenames, the CDN may still serve cached old HTML pointing to old assets. The fix: set HTML to no-cache so the CDN always revalidates, or purge HTML from the CDN cache as part of your deployment pipeline. Most CI/CD tools have plugins for this.

Frequently Asked Questions

What is the difference between Cache-Control no-cache and no-store?

no-cache allows the browser to store the response but requires revalidation with the server before every use. The browser sends a conditional request (using ETag or Last-Modified) and typically gets a fast 304 Not Modified back. no-store prevents any caching at all — the full resource gets downloaded on every request. Use no-cache for content that must always be current (like HTML pages) and no-store for sensitive data that should never be persisted (like account pages or payment flows).

How long should I cache static assets like JavaScript and CSS?

If your build tool adds content hashes to filenames (e.g., app-a1b2c3.js), cache for one year (max-age=31536000) with the immutable directive. The filename itself changes when the content changes, so there's no risk of serving stale code. If your files don't use content hashes, keep max-age short (minutes to hours) and rely on ETag validation to minimize bandwidth.

Does stale-while-revalidate work with all CDNs?

As of 2026, yes — all major CDNs support it: Cloudflare, Fastly, AWS CloudFront, Google Cloud CDN, Akamai, and Netlify all honor the directive. Browser support is universal in modern browsers too. That said, the specifics vary a bit — Cloudflare requires explicit opt-in for some configurations, and CloudFront processes it across 480+ edge locations with automatic background revalidation.

How do I prevent CDN caching for authenticated pages?

Use Cache-Control: private, no-store for pages containing user-specific data. The private directive tells shared caches (CDNs) not to store the response, and no-store ensures even the browser doesn't persist it. Alternatively, Cache-Control: private, max-age=0, must-revalidate lets the browser cache temporarily but always revalidate — this enables 304 responses for bandwidth savings while keeping the CDN out of the equation.

How do I check if my caching headers are working correctly?

In Chrome DevTools, open the Network panel and reload the page. Look at the Size column — cached resources show "(disk cache)" or "(memory cache)." Click any resource and check the Response Headers for your Cache-Control header. For CDN caching, look for headers like CF-Cache-Status: HIT (Cloudflare), X-Cache: Hit from cloudfront (AWS), or the Age header (which tells you how many seconds ago the CDN cached it). You can also use curl -I https://yoursite.com/asset.js from the command line to inspect headers without a browser.

About the Author Editorial Team

Our team of expert writers and editors.