Cache-Control Headers for Image and Video Assets

Proper Cache-Control configuration is the single highest-leverage operation in any media delivery pipeline. It dictates how browsers, CDNs, and intermediate proxies store, revalidate, and invalidate responses — directly controlling whether a user fetches an asset from an edge node 5 ms away or triggers a 200 ms round trip to your origin. As part of the Core Media Fundamentals & Next-Gen Formats discipline, mastering cache directives for image and video assets is prerequisite work before format conversion, codec selection, or adaptive streaming become meaningful optimisations. Aligning Cache-Control directives with immutable, content-hashed URLs reduces origin egress by 30–70% and improves repeat-visit LCP by 20–40%.

How HTTP Caching Works for Media Assets

The HTTP cache model separates the decision of who can cache a response from how long it stays fresh, and whether clients must revalidate before re-using it. Understanding each layer is essential for designing a caching policy that works correctly across browsers, CDN edges, and reverse proxies simultaneously.

The flow from first request to cached re-use follows three distinct phases:

HTTP Cache Flow for Media Assets Diagram showing three phases: cold miss (browser to CDN to origin), warm hit (browser to CDN only), and stale revalidation (CDN conditional request to origin returning 304). COLD MISS WARM HIT STALE REVALIDATION Browser CDN Edge Origin GET /img.avif cache miss 200 + headers 200 → stored Browser CDN Edge GET /img.avif HIT — cached copy max-age still fresh → origin never contacted Browser CDN Edge Origin GET (stale OK) serve stale now If-None-Match 304 Not Modified dashed = background / conditional origin round-trip stale-while-revalidate delivers the cached copy immediately; the refresh happens in the background

The Cache-Control directive grammar

Cache-Control is a comma-separated list of directives. For media delivery the relevant set is:

  • public — allows shared caches (CDN edges, reverse proxies) to store the response, even when the request carried an Authorization header.
  • private — restricts caching to the end-user’s browser; CDN edges must not store it.
  • max-age=N — the number of seconds the response remains fresh from the time it was generated. After expiry the cache must revalidate.
  • s-maxage=N — overrides max-age for shared caches (CDNs) while leaving browser max-age intact; useful when CDN TTL should differ from browser TTL.
  • immutable — signals that the response body will never change during its freshness lifetime; browsers skip conditional If-None-Match / If-Modified-Since requests on reload.
  • stale-while-revalidate=N — allows a stale response to be served for up to N seconds while a background revalidation request fetches a fresher version.
  • stale-if-error=N — allows a stale response to be served when the origin returns a 5xx error, for up to N seconds.
  • no-store — forbids all caching of the response; suitable for auth-gated or personalised media endpoints.

Warning: no-cache does not prevent caching — it forces revalidation on every request. Many developers use it when they intended no-store.

Cache Hit Rate Impact — Benchmark Data

The following figures are derived from CDN analytics across representative media-heavy sites. They show the relationship between max-age duration and the resulting edge cache hit ratio, measured over 30-day windows.

Asset type Cache strategy Typical edge hit ratio Origin egress reduction Repeat-visit LCP delta
Fingerprinted AVIF/WebP (hero images) public, max-age=31536000, immutable 96–99 % 65–75 % −25–40 %
Fingerprinted MP4/WebM (static clips) public, max-age=31536000, immutable 94–98 % 60–70 % −20–35 %
Editorial images (non-hashed, versioned by date) public, max-age=604800, stale-while-revalidate=86400 80–90 % 40–55 % −10–20 %
HLS/DASH segments (.ts, .m4s) public, max-age=31536000, immutable 92–97 % 55–65 % n/a (streaming)
HLS/DASH manifests (.m3u8, .mpd) public, max-age=60, stale-while-revalidate=120 40–60 % 20–30 % n/a
Thumbnails (CMS-generated, non-fingerprinted) public, max-age=86400, stale-while-revalidate=3600 70–82 % 35–50 % −5–15 %

Tradeoff: Hit ratio above 95 % requires content-hashed (fingerprinted) URLs. Without fingerprinting, CDN nodes treat every path as potentially mutable and apply shorter internal TTLs regardless of your max-age value.

Step-by-Step Implementation

Step 1 — Nginx configuration for static media

Apply per-extension location blocks to serve the correct directives. Fingerprinted assets get one-year immutable caching; non-hashed assets use a moderate max-age paired with stale-while-revalidate.

# Nginx — cache headers for media assets
# Assumes fingerprinted URLs contain a content hash (e.g. /assets/hero.8f4d2a.avif)

# Fingerprinted media: one-year immutable caching
location ~* \.(avif|webp|jpg|jpeg|png|gif|svg|mp4|webm|woff2)$ {
  # max-age=31536000 = 365 days; immutable tells browsers to skip
  # conditional requests (If-None-Match) on reload — saves one RTT per asset
  add_header Cache-Control "public, max-age=31536000, immutable";

  # Vary on Accept-Encoding only — adding Accept here would create separate
  # cache entries per MIME preference, causing CDN cache fragmentation
  add_header Vary "Accept-Encoding";

  # stale-if-error: serve the cached asset for 1 day if origin goes down
  add_header Cache-Control "stale-if-error=86400" always;

  expires 365d;
  access_log off; # omit from access logs to reduce I/O for static assets
}

# Non-fingerprinted editorial images (managed CMS, date-stamped not hashed)
location ~* /editorial/.*\.(jpg|jpeg|webp)$ {
  # 7-day freshness; stale-while-revalidate=86400 serves stale while background-
  # fetching fresh version — no user-visible latency on cache expiry
  add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400";
  add_header Vary "Accept-Encoding";
}

# HLS/DASH manifests — short TTL to pick up live playlist updates quickly
location ~* \.(m3u8|mpd)$ {
  # max-age=60: 1-minute freshness; stale-while-revalidate=120 prevents
  # simultaneous revalidation storms from all active viewers
  add_header Cache-Control "public, max-age=60, stale-while-revalidate=120";
  add_header Vary "Accept-Encoding";
  expires 60s;
}

# HLS segments and DASH chunks — immutable once encoded
location ~* \.(ts|m4s|fmp4)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  add_header Vary "Accept-Encoding";
  # Accept-Ranges is mandatory for byte-range requests from MSE (Media Source
  # Extensions) — missing this causes 206 failures at CDN edge nodes
  add_header Accept-Ranges "bytes";
  expires 365d;
}

Step 2 — Static hosting config (Vercel, Netlify, Cloudflare Pages)

All three platforms read a headers config file at the project root. The structure differs slightly between platforms.

Vercel (vercel.json):

{
  "headers": [
    {
      "source": "/assets/(.*\\.(?:avif|webp|jpg|jpeg|png|mp4|webm|woff2)$)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        },
        {
          "key": "Vary",
          "value": "Accept-Encoding"
        }
      ]
    },
    {
      "source": "/editorial/(.*\\.(?:jpg|jpeg|webp)$)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=604800, stale-while-revalidate=86400"
        }
      ]
    }
  ]
}

Netlify (netlify.toml):

# netlify.toml — cache headers for media assets
[[headers]]
  for = "/assets/*.avif"
  [headers.values]
    # immutable signals the browser to skip validation on forced reload (Shift+F5)
    Cache-Control = "public, max-age=31536000, immutable"
    Vary = "Accept-Encoding"

[[headers]]
  for = "/assets/*.webp"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"
    Vary = "Accept-Encoding"

[[headers]]
  for = "/assets/*.mp4"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"
    # Accept-Ranges required for HLS/MSE byte-range video streaming
    Accept-Ranges = "bytes"

Step 3 — Caddy configuration

Caddy’s declarative config makes header assignment concise:

# Caddyfile — cache headers for media assets
yourdomain.com {
  # Fingerprinted assets: immutable one-year caching
  @media {
    path_regexp media \.(avif|webp|jpg|jpeg|png|mp4|webm|woff2)$
  }
  header @media Cache-Control "public, max-age=31536000, immutable"
  header @media Vary "Accept-Encoding"

  # HLS manifests: short-lived with stale-while-revalidate
  @manifests {
    path_regexp manifests \.(m3u8|mpd)$
  }
  header @manifests Cache-Control "public, max-age=60, stale-while-revalidate=120"

  # Video segments: immutable once produced by encoder
  @segments {
    path_regexp segs \.(ts|m4s|fmp4)$
  }
  header @segments Cache-Control "public, max-age=31536000, immutable"
  header @segments Accept-Ranges "bytes"

  file_server
}

Step 4 — Apache .htaccess

For Apache environments (shared hosting, WordPress media), use mod_expires and mod_headers:


  ExpiresActive On

  # AVIF and WebP — format-negotiated next-gen images
  ExpiresByType image/avif "access plus 1 year"
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType video/mp4  "access plus 1 year"
  ExpiresByType video/webm "access plus 1 year"



  # Apply immutable to all hashed media — the fingerprint in the URL
  # is the versioning mechanism, so immutable is safe here
  
    Header set Cache-Control "public, max-age=31536000, immutable"
    Header set Vary "Accept-Encoding"
  

  # HLS manifests need short TTL for live stream playlist updates
  
    Header set Cache-Control "public, max-age=60, stale-while-revalidate=120"
  

Format-Specific Caching and the Vary: Accept Header

AVIF and WebP are served via <picture> format negotiation in HTML, which means the URL path already encodes the format — there is no need to add Vary: Accept to the response. Adding Vary: Accept when serving format-negotiated assets via separate URLs causes CDN cache fragmentation: the CDN creates a distinct cache entry for every unique Accept header string, multiplying storage and reducing hit ratios.

The Vary: Accept header is only correct when content negotiation happens server-side — i.e. the same URL returns different formats based on the browser’s Accept header (as in Cloudflare Image Resizing or ImageOptim’s auto-format endpoint). In that case you must add Vary: Accept to avoid serving WebP to Safari 13 or AVIF to Edge 18.

Format Delivery method Vary: Accept needed Reason
AVIF (image/avif) <picture> + separate URLs No URL path encodes format; no negotiation
WebP (image/webp) <picture> + separate URLs No Same as AVIF
AVIF/WebP CDN auto-format (same URL) Yes CDN negotiates format per Accept
VP9/AV1 WebM <video> + separate <source> No Browser selects first supported source
H.264 MP4 <video> baseline No No negotiation; single format per URL

Browser compat matrix for Cache-Control directive support

Directive Chrome 85+ Firefox 93+ Safari 14 Safari 16 Edge 18+
max-age Full Full Full Full Full
immutable Full Full Partial — requires explicit max-age alongside immutable; directive alone ignored Full Full
stale-while-revalidate Full Full No (ignored) No (ignored) Full
stale-if-error Full Full No No Full
s-maxage (CDN) n/a n/a n/a n/a n/a

Warning: Safari 14 and Safari 16 ignore stale-while-revalidate entirely. For media served to Safari, rely on ETag + Last-Modified revalidation rather than counting on background refresh behaviour.

Parameter Reference

  • max-age=31536000 — 365 days in seconds; the conventional one-year value for fingerprinted immutable assets. The HTTP spec caps interpreted freshness at one year regardless of larger values.
  • immutable — added in Chrome 61 and Firefox 49. Tells browsers the resource will not change while it is fresh, so they skip the conditional GET on user-initiated reload (Shift+F5 still bypasses it). Requires a correct max-age alongside it in Safari to take effect.
  • stale-while-revalidate=N — delivers stale content synchronously while issuing a background GET. N is the grace period in seconds after max-age expiry. Use 60–120 s for live manifests; 3 600–86 400 s for editorial assets.
  • s-maxage=N — CDN-specific TTL that overrides max-age for shared caches. Use when your CDN should hold assets longer than the browser’s local cache (e.g. s-maxage=31536000, max-age=3600 — 1 year at the edge, 1 hour in the browser).
  • Vary: Accept-Encoding — instructs caches to store separate entries per Content-Encoding (gzip vs. Brotli vs. identity). Always set this when serving compressed responses to prevent Brotli-encoded payloads being delivered to Safari 9 clients.
  • ETag — opaque validator token (typically a hash of the response body). Sent by the server; echoed back in If-None-Match on revalidation. For hashed asset URLs, strip ETag at the edge to eliminate unnecessary 304 round trips — the URL itself is the version signal.
  • stale-if-error=N — allows CDN and browser to serve a stale cached response if the origin returns a 5xx error. Set to 86 400 (24 h) on production media CDNs as a resilience baseline.

Tradeoffs and Edge Cases

Tradeoff: immutable locks you into URL-based versioning. If you deploy Cache-Control: max-age=31536000, immutable on non-fingerprinted URLs (e.g. /assets/hero.jpg without a hash), there is no mechanism to force browsers to fetch updated content short of the user clearing their cache. This makes immutable unsuitable for any URL that might serve different content over time.

Tradeoff: stale-while-revalidate is invisible to Safari. Safari 14 and Safari 16 do not implement stale-while-revalidate. Assets with expired max-age will block on synchronous revalidation in Safari, adding a full RTT before the asset is delivered. Design TTL values so that expiry coincides with low-traffic windows, or use preload hints to warm the cache before assets expire.

Warning: Vary: Accept on format-negotiated same-URL endpoints can cause CDN cache poisoning. If Vary: Accept is missing from a CDN-served auto-format endpoint, a Brotli-compressed AVIF response cached for one user may be served to a Safari 14 user that requested image/webp. Always add Vary: Accept when the CDN performs format negotiation on a single URL.

Tradeoff: HLS manifest caching vs. live stream lag. Manifests with max-age=60 introduce up to 60 s of playlist staleness for live streams. For sub-10 s latency requirements, set max-age=2, stale-while-revalidate=4. For VOD (video on demand) where manifests are static, use max-age=31536000, immutable to eliminate revalidation overhead.

Warning: Accept-Ranges must be explicit for video. Chrome’s Media Source Extensions prefetches video segments via byte-range requests. If origin servers do not send Accept-Ranges: bytes, CDN edge nodes may not cache partial content responses (HTTP 206), and <video> elements will stall on every segment boundary.

Tradeoff: s-maxage without a purge API. Using s-maxage=31536000 at your CDN without a programmatic purge endpoint (Cloudflare Cache API, Fastly Instant Purge, CloudFront invalidation) means a defective asset stays live at the edge for up to a year. Always pair long s-maxage values with a tested purge workflow before deploying to production.

Adaptive Bitrate Streaming: Manifest and Segment Cache Strategy

HLS and DASH separate the playlist/manifest (which changes as new segments become available) from the encoded segments themselves (which are immutable once written). Correct caching requires different directives for each component.

# HLS cache strategy — separate rules for manifests vs. segments

# Master playlist: rarely changes; moderate caching
location ~* \.m3u8$ {
  # max-age=60 gives CDN edges a 1-minute buffer before checking origin;
  # stale-while-revalidate=120 avoids thundering-herd on expiry
  add_header Cache-Control "public, max-age=60, stale-while-revalidate=120";
  add_header Content-Type "application/vnd.apple.mpegurl";
  # Segment URLs in the playlist reference absolute or relative paths —
  # ensure base URL is consistent across CDN PoPs to prevent path drift
}

# Media segments: immutable once transcoded
location ~* \.(ts|m4s|fmp4)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  # Range requests are how MSE fetches individual chunks; must be enabled
  # at origin AND confirmed supported by CDN (some proxy configs strip this)
  add_header Accept-Ranges "bytes";
}

For stale-if-error on live streams, set a conservative value (300–600 s) to maintain playback during brief origin hiccups without serving a manifest that has drifted too far behind the live point.

Debugging and Validation

Confirm headers via curl

# Inspect cache headers on a fingerprinted AVIF asset
curl -sI https://your-cdn.com/assets/hero.8f4d2a.avif \
  | grep -iE '(cache-control|vary|etag|age|x-cache|accept-ranges)'

# Expected output for an immutable asset:
# cache-control: public, max-age=31536000, immutable
# vary: Accept-Encoding
# age: 3742          ← seconds since the CDN cached this response
# x-cache: HIT       ← CDN hit indicator (header name varies by CDN)
# accept-ranges: bytes

# For HLS manifests, confirm short TTL:
curl -sI https://your-cdn.com/stream/live.m3u8 \
  | grep -iE '(cache-control|age)'
# cache-control: public, max-age=60, stale-while-revalidate=120
# age: 12

What to look for:

  • age: — the number of seconds the asset has been sitting in the CDN cache. If this is always 0, the asset is not being cached at the edge.
  • x-cache: HIT — CDN-specific header confirming an edge cache hit. Cloudflare uses cf-cache-status: HIT; AWS CloudFront uses x-cache: Hit from cloudfront.
  • Missing vary: Accept-Encoding alongside a gzip/Brotli response signals a misconfiguration that will serve compressed bytes to clients that didn’t negotiate compression.

Chrome DevTools validation

  1. Open Network tab, reload the page with cache enabled (no Shift+F5).
  2. Filter by Img or Media resource type.
  3. Click an asset and check the Headers tab — confirm Cache-Control matches your intent.
  4. In the Size column, (disk cache) confirms a browser cache hit; (memory cache) confirms an in-memory hit. A numeric byte count means a network fetch occurred.
  5. For the second visit, Status 200 from disk cache confirms immutable is working. A 304 Not Modified indicates the browser sent a conditional request — check whether immutable is actually being set.

Lighthouse and field data

Run Lighthouse in Performance mode and check the “Serve static assets with an efficient cache policy” audit. It flags assets with max-age under 31 536 000 s. Supplement with Chrome User Experience Report (CrUX) field data to confirm that repeat-visit LCP is improving in real browsers, not just lab conditions.

For MIME type validation alongside cache headers, run:

# Confirm Content-Type and Cache-Control together
curl -sI https://your-cdn.com/assets/hero.8f4d2a.avif \
  | grep -iE '(content-type|cache-control)'
# content-type: image/avif
# cache-control: public, max-age=31536000, immutable

If content-type is incorrect (e.g. application/octet-stream for an AVIF file), the browser will refuse to decode the image regardless of how well the cache headers are configured. Fix incorrect Content-Type headers before debugging cache behaviour.