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. Choosing the right window per asset — images, poster frames, and manifests each want a different one — is covered in stale-while-revalidate for media assets.
  • 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.

How a cache computes freshness

max-age is not measured from the moment the response reaches the browser — it is measured from the moment the origin generated it. RFC 9111 defines the response’s current age as the time already spent in upstream caches plus the time spent in transit, and every intermediary is required to advertise that accumulated value in the Age response header. A browser receiving Cache-Control: max-age=604800 alongside Age: 601200 has only 3 600 seconds of freshness left, not seven days. This is why a long max-age on a busy CDN can still produce browser revalidation traffic: the edge object was already old when it was handed downstream.

Two consequences follow. First, Age is the header to inspect when a browser appears to ignore your TTL — a large Age means the edge is serving a long-lived object, not that the directive was dropped. Second, if you want the edge and the browser to run independent clocks, you need s-maxage for the edge and a shorter max-age for the browser; the Age arithmetic then applies only to the shared cache.

When a response carries no explicit freshness directive at all, caches fall back to heuristic freshness: most implementations take 10 % of the interval between Date and Last-Modified and treat that as the TTL. A photo last modified 200 days before it was served therefore becomes implicitly cacheable for roughly 20 days — a silent, uncontrolled policy that produces exactly the stale-image bugs teams later blame on the CDN. Always set an explicit directive on media, even when the value you want is short.

Warning: Add no-transform to media responses served over networks that run transcoding proxies. Some mobile carriers and corporate middleboxes re-encode images to lower quality in transit; no-transform is the only directive that forbids it, and without it your carefully tuned AVIF can arrive as a recompressed JPEG.

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 %

Ranked on a common axis, the hit-ratio column splits into three distinct bands: everything with a content hash in the URL clears 90 %, everything time-versioned lands in the 70–90 % range, and manifests sit far below both because their short TTL is deliberate rather than a misconfiguration.

Edge cache hit ratio by asset class Horizontal bars showing observed 30-day edge hit ratios: fingerprinted images 96 to 99 percent, fingerprinted video 94 to 98, HLS and DASH segments 92 to 97, editorial images 80 to 90, CMS thumbnails 70 to 82, and HLS or DASH manifests 40 to 60 percent. Edge cache hit ratio by asset class, measured over 30-day windows Fingerprinted AVIF/WebP Fingerprinted MP4/WebM HLS/DASH segments Editorial images + SWR CMS thumbnails HLS/DASH manifests 96-99% 94-98% 92-97% 80-90% 70-82% 40-60% 0% 25% 50% 75% 100% Solid bar = lower bound of the observed range; the lighter tip extends to the upper bound. Amber: manifests are intentionally short-lived, so a low hit ratio there is correct.

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:

<IfModule mod_expires.c>
  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"
</IfModule>

<IfModule mod_headers.c>
  # Apply immutable to all hashed media — the fingerprint in the URL
  # is the versioning mechanism, so immutable is safe here
  <FilesMatch "\.(avif|webp|jpg|jpeg|png|mp4|webm)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
    Header set Vary "Accept-Encoding"
  </FilesMatch>

  # HLS manifests need short TTL for live stream playlist updates
  <FilesMatch "\.(m3u8|mpd)$">
    Header set Cache-Control "public, max-age=60, stale-while-revalidate=120"
  </FilesMatch>
</IfModule>

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.
  • must-revalidate — forbids serving a stale response once max-age has elapsed, even when the origin is unreachable. It cancels stale-if-error for that response, so never combine the two on the same asset. Reserve it for media whose staleness has legal or financial consequences (paywalled artwork, price-bearing product shots), not for general imagery.
  • no-transform — forbids intermediaries from re-encoding, downscaling, or transcoding the payload. This is a media-specific defence: without it, transcoding proxies on some mobile networks may recompress your images and invalidate every quality measurement you took at build time.
  • Age — response header reporting how many seconds the object has already spent in shared caches. Subtract it from max-age to get the freshness remaining at the client. A value that never rises above 0 means the edge is not caching at all.
  • Last-Modified — weaker validator than ETag (one-second granularity), but the only one Safari relies on when stale-while-revalidate is unavailable. Keep it on non-fingerprinted media even when ETag is present.

These directives are not alternatives to each other — they are consecutive windows on one timeline. A single response passes through each in turn, and the directive that governs its behaviour depends only on how far past its generation time the current request lands.

Freshness lifetime of one cached media response A timeline divided into four consecutive windows: fresh under max-age, stale within the stale-while-revalidate grace period, stale-if-error only on origin failure, and finally expired with blocking revalidation. Freshness lifetime of a single cached media response FRESH served from cache, 0 RTT max-age=604800 STALE + SWR serve stale instantly, revalidate in background STALE-IF-ERROR served only when origin returns 5xx EXPIRED blocking revalidation t = 0 - response generated t = max-age + stale-while-revalidate + stale-if-error Safari skips the SWR window entirely and blocks on revalidation as soon as max-age expires immutable suppresses the conditional GET for the whole fresh window; ETag only matters after it s-maxage replaces max-age at shared caches, so edge and browser can run different windows

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.