Best practices for setting max-age on CDN media assets

When every image and video on your site carries a one-year Cache-Control header, repeat visitors load those assets from a local or edge cache instead of hitting your origin β€” eliminating latency, reducing bandwidth cost, and directly lowering Largest Contentful Paint (LCP). The catch is that a one-year expiry is only safe when the URL changes with the content. This guide answers the exact question: how do you configure max-age, immutable, and stale-while-revalidate correctly so you get the performance benefit without ever serving a stale asset? It builds on the full Cache-Control Headers for Image and Video Assets reference, which covers header semantics, Vary configuration, and CDN-specific behaviour in depth.


Prerequisite checklist

Before applying an immutable one-year max-age, confirm every item below is true for your deployment:


The immutable directive: why max-age=31536000 is the baseline

Cache-Control: public, max-age=31536000, immutable
# public    β€” allow CDN edge nodes AND shared caches to store the response
# max-age=31536000 β€” 365 days in seconds; browsers and CDNs treat this as "cache forever"
# immutable β€” tells the browser NOT to issue conditional If-Modified-Since / If-None-Match
#             requests during page reloads; valid only when the URL is content-addressable

The immutable directive was designed precisely for fingerprinted static assets. Without it, every browser reload triggers a conditional GET β€” a round trip that returns 304 Not Modified but still costs 40–80 ms of latency per asset. With immutable, the browser skips that round trip entirely. The directive is supported in all browsers since 2018 and has no downside when URLs are truly content-addressed.

Tradeoff: If your build pipeline produces the same output filename despite a content change β€” for example, because an intermediate cache layer returns a stale input to the hash function β€” users will receive that stale media for up to 365 days with no way to force a refresh short of a CDN purge. Validate hash generation in CI before enabling immutable in production.


How content hashing and CDN caching interact

The diagram below shows the lifecycle: build pipeline produces a hashed URL; the CDN caches the response on first request; all subsequent requests for that URL are served from the edge without hitting the origin.

Content-hashed URL lifecycle from build pipeline to CDN edge cache Three columns β€” Build Pipeline, CDN Edge, and Browser β€” showing how a fingerprinted asset URL flows through hashing, first-request caching, and subsequent cache-hit serving without an origin round trip. Build Pipeline CDN Edge Browser hash(bytes) β†’ .8f4d2a.avif deploy / upload GET .8f4d2a.avif (MISS) GET from origin 200 + Cache-Control: max-age=31536000,immutable edge stores response 200 served to browser GET .8f4d2a.avif (HIT β€” no origin)

Exact solution: Nginx, Vite, and Cloudflare Workers examples

Nginx: scoped immutable headers

# Serve hashed static media with a one-year immutable cache.
# Pattern targets filenames that contain an 8-hex-character content hash
# followed by the extension, e.g. hero.8f4d2a1b.avif
location ~* /media/.*\.[a-f0-9]{8}\.(avif|webp|jpg|jpeg|png|mp4|webm)$ {

    # public β€” allow CDN edge nodes and shared proxies to cache the response
    # max-age=31536000 β€” 365 Γ— 24 Γ— 3600 seconds; treated as "permanent" by CDNs
    # immutable β€” suppress conditional revalidation on browser reload (RFC 8246)
    add_header Cache-Control "public, max-age=31536000, immutable";

    # Vary: Accept tells CDN to maintain separate cache entries per Accept header value.
    # Required when your server negotiates AVIF vs WebP vs JPEG at the same hashed URL.
    # If the URL already encodes the format (e.g. .8f4d2a.avif), Vary: Accept is redundant
    # but harmless; omit it if every format variant has a distinct hashed URL.
    add_header Vary "Accept";

    # ETag is counterproductive on hashed assets: the browser would still send
    # If-None-Match on a hard reload, wasting a round trip. Disable it.
    etag off;

    # Turn off Last-Modified for the same reason β€” no conditional GET round trips.
    add_header Last-Modified "";
}

# Un-hashed HTML and manifests get a short TTL so deployments propagate quickly.
location ~* \.(html|json)$ {
    add_header Cache-Control "public, max-age=0, must-revalidate";
}

Vite: content-hashing configuration

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

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // [hash] inserts an 8-character content hash derived from the file's bytes.
        // Changing a single pixel in a PNG changes the hash and therefore the URL,
        // making the old CDN cache entry permanently unreachable.
        assetFileNames: 'assets/[name].[hash][extname]',
        chunkFileNames: 'assets/[name].[hash].js',
        entryFileNames: 'assets/[name].[hash].js',
      },
    },
  },
});

Cloudflare Workers: edge Cache API with immutable TTL

// worker.js β€” intercept requests for hashed media and apply immutable caching.
// Deploy this as a route in front of your R2 bucket or origin.
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Regex: matches filenames with an 8-char hex hash before the extension.
    const isHashedAsset = /\.[a-f0-9]{8}\.(avif|webp|jpg|jpeg|png|mp4|webm)$/.test(url.pathname);

    if (!isHashedAsset) {
      // Pass non-hashed requests straight through; do not cache HTML here.
      return fetch(request);
    }

    const cache = caches.default;
    const cached = await cache.match(request);
    if (cached) return cached; // Cache HIT β€” origin is never contacted.

    const response = await fetch(request);

    // Clone before modifying headers (Response bodies are one-time-read streams).
    const modified = new Response(response.body, response);

    modified.headers.set(
      'Cache-Control',
      'public, max-age=31536000, immutable'
      // stale-while-revalidate=86400 could be added here as a safety net,
      // but is unnecessary when URLs change on every content change.
    );

    // Delete ETag β€” prevents conditional revalidation on hard reload.
    modified.headers.delete('ETag');
    modified.headers.delete('Last-Modified');

    // Store in the Cloudflare edge cache for subsequent requests.
    await cache.put(request, modified.clone());

    return modified;
  },
};

Service worker: offline and repeat-visit acceleration

// sw.js β€” cache hashed media during fetch for offline availability
// and to complement the CDN cache with a device-level store.
self.addEventListener('fetch', (event) => {
  const url = event.request.url;

  // Only intercept hashed media paths; let HTML and API requests bypass.
  const isHashedMedia =
    url.includes('/assets/') &&
    /\.[a-f0-9]{8}\.(avif|webp|jpg|mp4|webm)$/.test(url);

  if (!isHashedMedia) return;

  event.respondWith(
    caches.open('media-v1').then(async (cache) => {
      const hit = await cache.match(event.request);
      if (hit) return hit; // Served from device cache β€” zero network.

      const response = await fetch(event.request);
      // Only cache successful responses; don't cache 4xx/5xx errors.
      if (response.ok) {
        cache.put(event.request, response.clone());
      }
      return response;
    })
  );
});

Tradeoff: Service workers add fine-grained offline control but introduce a cache lifecycle that must be managed separately from the CDN. Name your cache stores with version suffixes (media-v2) and delete old caches in the activate event so stale device-side entries are purged when you deploy a breaking change.


Verification steps

Step 1: inspect headers with curl

# -s suppress progress; -I fetch headers only (HEAD request).
# Replace with a real fingerprinted URL from your staging environment.
curl -sI "https://cdn.example.com/assets/hero.8f4d2a1b.avif" \
  | grep -iE '(cache-control|etag|vary|last-modified|x-cache)'

# Expected output on first request (MISS):
# cache-control: public, max-age=31536000, immutable
# vary: Accept
# x-cache: MISS                   ← CDN populated the cache with this request
# (no etag or last-modified lines)

# Run the same command a second time:
# x-cache: HIT                    ← Edge is now serving without contacting origin

Step 2: confirm browser cache behaviour in DevTools

  1. Open Chrome DevTools, go to the Network tab, and disable the β€œDisable cache” checkbox.
  2. Load the page, then reload it.
  3. Find a media asset in the waterfall. The Size column should read (disk cache) or (memory cache) β€” not a byte count.
  4. The Status should be 200 with a grey circle icon, not 304. A 304 means the browser sent a conditional request; immutable should have prevented this.
  5. Click the asset row, open Headers, and confirm Cache-Control is public, max-age=31536000, immutable.

Step 3: check CDN hit ratio

Within 24 hours of deploying, open your CDN analytics dashboard and verify:

  • Cache hit ratio is above 95% for the hashed-asset path pattern.
  • Origin egress bandwidth has dropped proportionally to the hit ratio increase.
  • No 5xx errors appear on the origin log that would indicate the CDN is over-fetching.

Step 4: verify LCP improvement with Lighthouse

# Run Lighthouse CLI against a page that contains a hashed LCP image.
# --only-categories=performance avoids running accessibility/SEO audits.
npx lighthouse "https://staging.example.com/" \
  --only-categories=performance \
  --output=json \
  --output-path=./lcp-report.json \
  --chrome-flags="--headless"

# Then inspect the LCP value:
jq '.audits["largest-contentful-paint"].displayValue' lcp-report.json

A correct immutable-cache configuration typically improves LCP by 15–30% on repeat visits because the browser loads the LCP image from disk cache with near-zero latency rather than waiting for a CDN round trip.


Expected performance deltas

Metric Typical improvement How to measure
TTFB per hashed asset (repeat visit) 40–80 ms reduction Chrome DevTools > Timing tab
LCP (repeat visit) 15–30% decrease Lighthouse, WebPageTest repeat-view
CDN edge cache hit ratio 95–99% CDN analytics dashboard
Origin egress bandwidth 70–85% reduction Origin access logs
Service worker cache hit (offline) 100% of cached assets DevTools > Application > Cache Storage

Common mistakes and fixes

Mistake 1: applying immutable to un-hashed URLs

# WRONG β€” URL does not change when content changes
Cache-Control: public, max-age=31536000, immutable
# applied to: /images/hero.avif (static name, content can change at deploy)

If you update /images/hero.avif at deploy time, the browser and CDN will continue serving the old version for a year. Users can only escape by clearing their browser cache.

# CORRECT β€” URL is content-addressed; a new hash forces cache bypass
Cache-Control: public, max-age=31536000, immutable
# applied to: /assets/hero.8f4d2a1b.avif

Mistake 2: omitting Vary: Accept on format-negotiated assets

When a server returns AVIF to Chrome and JPEG to Safari 14 for the same URL, a CDN without Vary: Accept will cache whichever format arrived first and serve it to all browsers. Safari 14 cannot decode AVIF β€” the result is a broken image.

# Add to all responses where the format depends on Accept header negotiation:
Vary: Accept

For detailed guidance on Vary header configuration alongside MIME type settings for modern media servers, see the debugging incorrect Content-Type headers for WebM videos guide, which covers CDN poisoning patterns that apply equally to image format negotiation.

Mistake 3: leaving ETag enabled on hashed assets

Even with max-age=31536000, immutable, some browsers send a conditional If-None-Match request on a hard reload (Shift+Reload) if the server provided an ETag. The CDN then contacts the origin to validate. The origin responds 304 β€” so no bandwidth is wasted β€” but the round trip adds 40–80 ms of unnecessary latency.

Fix: disable ETag at the CDN or origin server for hashed-asset paths, as shown in the Nginx snippet above (etag off;).

Mistake 4: using a timestamp or build number as a β€œhash”

A timestamp (hero.20241101.avif) changes every build regardless of content. This invalidates CDN cache entries unnecessarily and eliminates the performance benefit for unchanged assets between deploys. Use a content-derived hash β€” SHA-256 or MD5 of the file bytes β€” so the URL only changes when the file actually changes.

Mistake 5: not updating HTML references atomically

If your CI/CD pipeline updates asset files before updating the HTML that references them, there is a window during which the live HTML points at old hashed URLs that no longer exist in the new deploy. Requests during this window get 404 errors. Always deploy assets first, then the HTML that references them β€” or use atomic deployment (Cloudflare Pages, Netlify, Vercel) where the entire build is swapped in a single operation.


Failure recovery: emergency cache invalidation

When a defective asset has already been pushed to the CDN with a one-year max-age, a CDN purge is the only recovery path (you cannot rely on browser-side expiry).

# Cloudflare API: purge a specific URL from the edge cache.
# Replace {ZONE_ID} and {API_TOKEN} with your actual values from the Cloudflare dashboard.
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer {API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "files": [
      "https://cdn.example.com/assets/hero.8f4d2a1b.avif"
    ]
  }'
# Response: {"result":{"id":"<purge_job_id>"},"success":true}
# Propagation to all edge nodes typically takes 30 seconds to 2 minutes.

After purging, deploy a new build that produces a new hash for the corrected asset β€” this ensures that any clients who already received the old response (and cached it in their browser) will pick up the new URL from the updated HTML.

Warning: stale-while-revalidate can create a window where the CDN serves the purged-but-not-yet-updated content from a stale buffer. If you add stale-while-revalidate=86400 as a safety net, account for up to 24 hours of overlap after a purge:

Cache-Control: public, max-age=31536000, immutable, stale-while-revalidate=86400
# stale-while-revalidate=86400 β€” serve from stale buffer for up to 24 hours
#                                 while revalidating in the background.
# Only useful if the URL is NOT content-addressed; for hashed assets,
# prefer deploying a new hash over relying on this directive.