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:
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 anAuthorizationheader.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— overridesmax-agefor shared caches (CDNs) while leaving browsermax-ageintact; useful when CDN TTL should differ from browser TTL.immutable— signals that the response body will never change during its freshness lifetime; browsers skip conditionalIf-None-Match/If-Modified-Sincerequests 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 conditionalGETon user-initiated reload (Shift+F5 still bypasses it). Requires a correctmax-agealongside it in Safari to take effect.stale-while-revalidate=N— delivers stale content synchronously while issuing a backgroundGET. N is the grace period in seconds aftermax-ageexpiry. Use 60–120 s for live manifests; 3 600–86 400 s for editorial assets.s-maxage=N— CDN-specific TTL that overridesmax-agefor 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 perContent-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 inIf-None-Matchon revalidation. For hashed asset URLs, stripETagat 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 usescf-cache-status: HIT; AWS CloudFront usesx-cache: Hit from cloudfront.- Missing
vary: Accept-Encodingalongside a gzip/Brotli response signals a misconfiguration that will serve compressed bytes to clients that didn’t negotiate compression.
Chrome DevTools validation
- Open Network tab, reload the page with cache enabled (no Shift+F5).
- Filter by Img or Media resource type.
- Click an asset and check the Headers tab — confirm
Cache-Controlmatches your intent. - 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. - For the second visit,
Status 200from disk cache confirmsimmutableis working. A304 Not Modifiedindicates the browser sent a conditional request — check whetherimmutableis 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.
Related
- Best practices for setting max-age on CDN media assets — deep dive on
s-maxage, CDN purge workflows, and service worker cache interception - MIME type configuration for modern media servers — ensure
Content-Typeis correct before relying on cache headers - Debugging incorrect Content-Type headers for WebM videos — fix
application/octet-streammisidentification at the server level - Understanding video codecs: VP9 vs H.265 vs AV1 — codec selection context for setting per-format cache policies
- Preload vs prefetch for video and image assets — combine preload hints with long-lived caching for sub-200 ms LCP on repeat visits