MIME Type Configuration for Modern Media Servers
Correct Content-Type declarations are the foundation of every modern media pipeline. When a server sends the wrong MIME type — or omits it entirely — browsers fall back to binary sniffing, which blocks the render tree, forces redundant fallback downloads, and prevents hardware-accelerated decoder initialization. This guide is part of Core Media Fundamentals & Next-Gen Formats and covers every server platform in production use, from Nginx mime.types overrides to edge header injection in Cloudflare Workers. Paired with correct Cache-Control headers for image and video assets, MIME precision is the first step toward eliminating wasted bandwidth and improving LCP times by 15–30%.
How HTTP Content Negotiation Works for Media Assets
The browser and server negotiate which format to serve through a sequence of HTTP headers. The browser advertises its format capabilities in the Accept request header; the server must then respond with a matching Content-Type and, critically, a Vary: Accept response header so CDN edge nodes cache separate representations per capability tier.
The diagram below shows the full negotiation flow for a single image asset:
Three headers govern this exchange:
Content-Type— the authoritative MIME declaration the browser uses to select a decoder. Without it, Chrome attempts sniffing but Safari frequently refuses to decode.Vary: Accept— tells every intermediate cache (CDN, reverse proxy, shared cache) to store separate responses for each distinctAcceptvalue. Omitting this causes WebP-capable browsers to receive JPEG responses cached for older agents, or vice versa.X-Content-Type-Options: nosniff— instructs browsers to honour the declaredContent-Typeand skip sniffing entirely. Required for security and for deterministic decoder routing.
Warning: Missing Vary: Accept causes CDN cache poisoning — a browser that does not support AVIF may receive a cached AVIF response, causing a blank image with no console error. Always verify the response header is present on every route that serves format-negotiated assets.
The Accept header itself is an ordered preference list, not a flat set. Chrome sends image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8, where the absence of an explicit q parameter means q=1.0 and the trailing */*;q=0.8 marks “anything else, but I’d rather not”. A conforming negotiator sorts candidate representations by the client’s quality value, then by its own server-side preference, and picks the highest scoring pair — which is why a server that merely does a substring test for image/avif will happily serve AVIF to a client that listed it at q=0 to explicitly reject it. Explicit rejection is rare from browsers but common from image proxies, link unfurlers, and social-media crawlers, and it is the reason format negotiation should compare parsed media ranges rather than raw strings.
What Browsers Do When Content-Type Is Missing or Wrong
Sniffing is not a vague fallback; it is a specified algorithm. The WHATWG MIME Sniffing Standard says a browser only sniffs when the supplied type is absent, is application/octet-stream, or is an “unknown” type such as text/plain — and it never sniffs when X-Content-Type-Options: nosniff is present. When sniffing does run, the browser reads a prefix of the body (up to 1445 bytes) and matches it against a signature table. The result is that a wrongly typed image usually still renders in Chrome, which is exactly why MIME bugs survive code review and only surface later as decoder failures, blocked cross-origin loads, or a CDN caching the wrong representation.
| Format | Leading signature bytes | Offset | ASCII form |
|---|---|---|---|
| JPEG | FF D8 FF |
0 | — |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
0 | .PNG.... |
| WebP | 52 49 46 46 … 57 45 42 50 |
0 and 8 | RIFF…WEBP |
| AVIF (still) | 66 74 79 70 61 76 69 66 |
4 | ftypavif |
| AVIF sequence | 66 74 79 70 61 76 69 73 |
4 | ftypavis |
| JPEG XL (raw codestream) | FF 0A |
0 | — |
| JPEG XL (ISO-BMFF container) | 00 00 00 0C 4A 58 4C 20 |
0 | ....JXL |
| WebM / Matroska | 1A 45 DF A3 |
0 | EBML header |
| MP4 / fMP4 init segment | 66 74 79 70 |
4 | ftyp |
DASH media segment (.m4s) |
73 74 79 70 |
4 | styp |
Two limits matter. First, the sniffing table only covers images and a handful of top-level types — <video>, <audio>, and Media Source Extensions never sniff. SourceBuffer selection happens through MediaSource.isTypeSupported() and addSourceBuffer(), both of which take a full MIME string with a codecs parameter and throw NotSupportedError on anything they do not recognise, so an application/octet-stream segment is simply unplayable. Second, sniffing does not repair the side effects of a wrong type: Response.blob().type, Cross-Origin-Resource-Policy checks, and CDN content-type-based optimisation rules all read the declared header, not the sniffed result.
# Confirm what the bytes actually are before blaming the server config.
xxd -l 16 hero.avif
# 00000000: 0000 001c 6674 7970 6176 6966 0000 0000 ....ftypavif....
# ^^^^^^^^^^^^^^^^^ 'ftypavif' at offset 4 → genuine AVIF
# libmagic's opinion, which is what most upload pipelines use to set metadata:
file --mime-type -b hero.avif # image/avif (libmagic 5.39+; older builds say application/octet-stream)
# Compare it with what the server claims:
curl -sI https://cdn.your-domain.com/images/hero.avif | grep -i '^content-type'
# A mismatch here — correct bytes, wrong header — is a server config bug, not an encoder bug.
Warning: file --mime-type on a build machine with libmagic older than 5.39 reports application/octet-stream for AVIF. Upload scripts that pipe file output straight into an object-storage Content-Type field therefore bake the wrong type into the object metadata, where it survives every later CDN purge until the object is re-uploaded.
IANA MIME Types and Browser Support Matrix
MIME types for modern image and video formats are registered with IANA and must be used exactly as specified — servers that return image/x-avif or video/x-webm will cause negotiation failures in strict parsers. The AV1 video codec in particular requires a fully-qualified codec parameter string in the Content-Type when serving fragmented MP4 (fmp4) to MSE pipelines.
| Format | Correct IANA MIME Type | Chrome / Edge | Firefox | Safari 14 | Safari 16+ |
|---|---|---|---|---|---|
| AVIF (still image) | image/avif |
85+ | 93+ | No | 16+ |
| WebP | image/webp |
23+ | 65+ | 14+ | 14+ |
| JPEG XL | image/jxl |
Removed (v110) | No | 17+ | 17+ |
| AV1 in MP4 | video/mp4; codecs="av01.0.05M.08" |
70+ | 67+ | No | 16.4+ |
| VP9 in WebM | video/webm; codecs="vp9" |
32+ | 28+ | 14+ (limited) | 14+ (limited) |
| H.265 in MP4 | video/mp4; codecs="hvc1.1.6.L93.B0" |
104+ (Win only) | No | 11+ | 11+ |
| H.264 in MP4 | video/mp4; codecs="avc1.42E01E" |
4+ | 35+ | 3.1+ | 3.1+ |
| DASH manifest | application/dash+xml |
27+ | 47+ | No | No |
| HLS playlist | application/vnd.apple.mpegurl |
Via MSE | Via MSE | 3+ (native) | 3+ (native) |
Quirk note: Safari requires exact codec string casing in both the HTTP Content-Type and the HTML <source type> attribute. A lowercase av01 works; AV01 does not. Always verify decoder readiness at runtime via HTMLMediaElement.canPlayType() before committing to a format variant — it returns "probably", "maybe", or "" (unsupported).
// Probe AV1 support before attempting to set a video src
const probe = document.createElement('video');
const av1Support = probe.canPlayType('video/mp4; codecs="av01.0.05M.08"');
// "probably" → safe to use AV1; "maybe" → server must send; "" → skip entirely
if (av1Support === 'probably' || av1Support === 'maybe') {
videoEl.src = 'clip.av1.mp4';
} else {
videoEl.src = 'clip.vp9.webm'; // VP9/WebM universal fallback
}
Codec Parameter Strings, Field by Field
For video, the MIME type alone is not enough information for a decoder to commit. video/mp4 says only “ISO base media file format”; the codecs parameter, defined by RFC 6381, carries the profile, level, tier, and bit depth the decoder must be able to handle. AV1 uses the dot-separated grammar av01.<profile>.<level><tier>.<bitDepth>, with optional trailing fields for chroma subsampling and colour primaries that may be omitted when they match the defaults. Every field is compared literally, and a value the client cannot decode makes canPlayType() return the empty string — the <source> is skipped in silence, with no console error to explain it.
The same grammar governs the other codecs you are likely to ship, and the differences are unforgiving:
| Codec string | Decodes as | Where it must match exactly |
|---|---|---|
av01.0.05M.08 |
AV1 Main profile, level 5.0, Main tier, 8-bit | Content-Type, <source type>, MediaSource.isTypeSupported() |
av01.0.05M.10 |
AV1 Main profile, 10-bit (HDR10 ladder) | Must match the encoder’s --bit-depth; an 8-bit encode advertised as .10 fails on strict decoders |
av01.1.05M.08 |
AV1 High profile, 4:4:4 chroma | Screen-capture and text-heavy content only; no hardware decode on most mobile SoCs |
hvc1.1.6.L93.B0 |
HEVC Main, level 3.1, parameter sets out of band | Safari/iOS HLS requires hvc1, not hev1 |
hev1.1.6.L93.B0 |
HEVC Main, parameter sets in band | Works in MSE pipelines; produces a black frame in native Safari HLS |
vp09.00.10.08 |
VP9 profile 0, level 1.0, 8-bit | WebM and MP4 alike; older Chrome accepts the legacy short form vp9 |
avc1.42E01E |
H.264 Constrained Baseline, level 3.0 | The safest <source> of last resort |
avc1.640028 |
H.264 High profile, level 4.0 | Needed above 1080p30; rejected by some smart-TV browsers |
mp4a.40.2 |
AAC-LC audio | Append to the video string: codecs="av01.0.05M.08, mp4a.40.2" |
opus |
Opus audio in WebM or MP4 | Pair with video/webm; codecs="vp9, opus" |
Quirk note: the hvc1 versus hev1 distinction is the single most expensive one-character difference in media delivery. Both are valid HEVC codec strings and both pass canPlayType() in some builds, but hev1 keeps parameter sets in the elementary stream, which native Safari HLS refuses to initialise from. Re-mux with ffmpeg -tag:v hvc1 rather than re-encoding — the pixels are already correct.
Step-by-Step Implementation
Step 1 — Nginx: Register types in mime.types
Nginx ships with a built-in mime.types file that predates AVIF and AV1. Override it with an explicit types {} block inside your http {} context (or inside a specific server {} block for single-vhost precision):
# /etc/nginx/conf.d/media-types.conf
# Place this inside the http{} block or in a dedicated include file.
types {
# Modern image formats
image/avif avif; # AVIF still image — RFC draft, IANA registered 2021
image/webp webp; # WebP lossless/lossy
image/jxl jxl; # JPEG XL — Safari 17+ only; ship with <picture> fallback
# Video containers and codecs
video/webm webm; # WebM container (VP8/VP9/AV1)
video/mp4 mp4 m4v m4s; # MP4 and MPEG-DASH segments (.m4s)
video/ogg ogv; # Ogg/Theora — legacy only
# Adaptive streaming manifests
application/dash+xml mpd; # MPEG-DASH manifest
application/vnd.apple.mpegurl m3u8; # HLS playlist — required for Safari native HLS
# Fonts (often missed, causes render-blocking if wrong)
font/woff2 woff2;
font/woff woff;
}
# Force browsers to honour Content-Type; suppress sniffing sitewide
add_header X-Content-Type-Options "nosniff" always;
# Vary header for routes that serve format-negotiated images
# (set this in the location block that handles /images/ or your CDN origin path)
# add_header Vary "Accept" always;
Apply and test:
nginx -t && systemctl reload nginx
# Verify the response headers for an AVIF asset
curl -sI -H 'Accept: image/avif' https://your-cdn-origin.example/hero.avif \
| grep -E 'content-type|vary|x-content-type'
# Expected output:
# content-type: image/avif
# vary: Accept
# x-content-type-options: nosniff
Step 2 — Apache: AddType directives and mod_headers
Apache’s default mime.types file (usually /etc/mime.types) is slow to adopt new format registrations. Add the missing types either in httpd.conf, a <VirtualHost> block, or a per-directory .htaccess. For a focused walkthrough of the AVIF case on both Apache and Caddy — including the AllowOverride pitfall and the header vs mime directive choice — see configuring the AVIF MIME type on Apache and Caddy:
# .htaccess or httpd.conf — modern media MIME types
# Requires: mod_mime, mod_headers
# Image formats
AddType image/avif .avif
AddType image/webp .webp
AddType image/jxl .jxl
# Video formats
AddType video/webm .webm
AddType video/mp4 .mp4 .m4v .m4s
# Adaptive streaming
AddType application/dash+xml .mpd
AddType application/vnd.apple.mpegurl .m3u8
# Security: block MIME sniffing for all responses
Header always set X-Content-Type-Options "nosniff"
# Cache: send Vary on image routes so CDN caches per Accept tier
# Scope this to your image directory to avoid broad cache fragmentation
<FilesMatch "\.(avif|webp|jxl|jpg|png)$">
Header always set Vary "Accept"
</FilesMatch>
Tradeoff: Setting Vary: Accept globally on an Apache vhost fragments CDN cache across every Accept permutation, including Accept headers for HTML negotiation. Scope Vary: Accept only to image routes using <FilesMatch> or <Location> blocks to avoid unnecessary cache fragmentation on non-media responses.
Step 3 — Caddy: MIME types in Caddyfile
Caddy v2 handles many types automatically but does not register AVIF or AVIF sequences by default. Use the header directive:
# Caddyfile
your-origin.example {
root * /var/www/media
# Override Content-Type for modern image formats
@avif path *.avif
header @avif Content-Type "image/avif"
header @avif X-Content-Type-Options "nosniff"
header @avif Vary "Accept"
@webp path *.webp
header @webp Content-Type "image/webp"
header @webp X-Content-Type-Options "nosniff"
header @webp Vary "Accept"
@webm path *.webm
header @webm Content-Type "video/webm"
header @webm X-Content-Type-Options "nosniff"
file_server
}
Step 4 — Cloudflare Workers: Edge header injection
When the origin cannot be reconfigured (legacy CDN, third-party storage bucket, or a managed hosting platform), a Cloudflare Worker can inject correct headers at the edge. This approach also avoids adding a round-trip to origin for header fixes:
// worker.js — Cloudflare Worker (ES module syntax, wrangler.toml: main = "worker.js")
// Maps file extensions to authoritative MIME types.
// Runs at edge; no origin round-trip for MIME corrections.
const MIME_MAP = {
'.avif': 'image/avif',
'.webp': 'image/webp',
'.jxl': 'image/jxl',
'.webm': 'video/webm',
'.mp4': 'video/mp4',
'.m4s': 'video/mp4', // DASH segments use mp4 container
'.mpd': 'application/dash+xml',
'.m3u8': 'application/vnd.apple.mpegurl',
};
const IMAGE_EXTS = new Set(['.avif', '.webp', '.jxl', '.jpg', '.jpeg', '.png']);
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Extract extension — pathname may include query strings from some origins
const ext = url.pathname.match(/(\.[a-z0-9]+)$/i)?.[1]?.toLowerCase();
const response = await fetch(request);
const headers = new Headers(response.headers);
if (ext && MIME_MAP[ext]) {
headers.set('Content-Type', MIME_MAP[ext]);
}
// Prevent browser sniffing — must be on every response, not just matched ones
headers.set('X-Content-Type-Options', 'nosniff');
// Scope Vary: Accept to image routes only — prevents CDN cache fragmentation
// on video and manifest responses where Accept doesn't drive format selection
if (ext && IMAGE_EXTS.has(ext)) {
headers.set('Vary', 'Accept');
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
Warning: Using ES module export default syntax requires wrangler.toml to declare main = "worker.js" and compatibility_date of "2023-01-01" or later. The legacy addEventListener('fetch', ...) pattern does not support env bindings and should not be used for new Workers.
Step 5 — Content negotiation HTML patterns
Correct server MIME configuration enables the browser to honour <picture> source selection and <video> source ordering. The type attribute on each <source> is matched against the browser’s decoder registry — it is not just a hint:
<!-- Picture element: browser picks first <source> whose type it supports.
Requires server to actually serve image/avif at hero.avif — mismatches cause blank images. -->
<picture>
<source srcset="hero.avif" type="image/avif">
<!-- WebP fallback for Safari 14 and Chrome < 85 -->
<source srcset="hero.webp" type="image/webp">
<!-- JPEG baseline — no type attribute needed; always supported -->
<img src="hero.jpg" alt="Promotional banner: media delivery pipeline overview"
loading="lazy" <!-- below-fold: defer fetch -->
decoding="async" <!-- offload decode from main thread -->
width="1200" height="630"> <!-- reserve layout space, eliminating CLS -->
</picture>
<!-- Video element: sources tried in order; first supported type wins.
codec string must match exactly what the browser decoder registry expects. -->
<video controls
preload="metadata" <!-- fetch duration/dimensions; skip full download -->
width="1280" height="720"
poster="poster.webp"
aria-label="Product demonstration: configuring MIME types on Nginx">
<!-- AV1 in fMP4 — best compression, Chrome 70+, Firefox 67+, Safari 16.4+ -->
<source src="clip.av1.mp4" type='video/mp4; codecs="av01.0.05M.08"'>
<!-- VP9 in WebM — universal modern fallback; Safari has limited support -->
<source src="clip.vp9.webm" type='video/webm; codecs="vp9"'>
<!-- H.264 baseline — maximum compatibility, including older Safari and Edge -->
<source src="clip.h264.mp4" type='video/mp4; codecs="avc1.42E01E"'>
<track kind="captions" src="captions.vtt" srclang="en" label="English">
<p>HTML5 video is not supported. <a href="clip.h264.mp4">Download the video (MP4)</a>.</p>
</video>
Step 6 — Object storage: fix the type at upload time
S3, R2, and GCS do not derive Content-Type from the object key at request time. The value is frozen into the object’s metadata at PUT and replayed verbatim on every GET forever, so a single bad upload survives CDN purges, cache-rule changes, and edge Workers that only add headers. The SDK default when the client cannot guess is binary/octet-stream — the exact value that makes <video> refuse to initialise and .m3u8 manifests fail to parse:
# Upload a single asset with an explicit type. --content-type is metadata, not a header rule:
aws s3 cp hero.avif s3://media-bucket/images/hero.avif \
--content-type image/avif \
--cache-control "public, max-age=31536000, immutable"
# Bulk-fix objects already stored with the wrong type. --metadata-directive REPLACE is
# mandatory: without it, a copy-onto-itself preserves the existing (wrong) metadata.
aws s3 cp s3://media-bucket/videos/ s3://media-bucket/videos/ \
--recursive --exclude "*" --include "*.m3u8" \
--metadata-directive REPLACE \
--content-type application/vnd.apple.mpegurl \
--cache-control "public, max-age=6, stale-while-revalidate=30"
# Cloudflare R2 via wrangler — same principle, different flag name.
npx wrangler r2 object put media-bucket/segments/init.m4s \
--file ./init.m4s \
--content-type video/mp4
# Audit what is actually stored, without downloading the body:
aws s3api head-object --bucket media-bucket --key images/hero.avif \
--query '{type:ContentType,cache:CacheControl}' --output table
Warning: aws s3 sync guesses types from Python’s mimetypes module, which resolves .avif, .jxl, and .m4s only on interpreters shipping a recent mime.types database. Pin the type explicitly for media extensions in CI rather than trusting the guess — a wrong value written here is invisible to every downstream server config, because the storage layer is the origin. The same trap applies in reverse to the max-age values you set on those objects: metadata written once is served for a year.
Parameter Reference
| Parameter / Header | Where used | Effect | Common mistake |
|---|---|---|---|
Content-Type: image/avif |
HTTP response header | Routes to AVIF decoder | Using image/x-avif — not IANA registered, rejected by strict parsers |
Vary: Accept |
HTTP response header | Keys CDN cache per Accept tier | Omitting it — causes JPEG/AVIF cache collision on shared CDN nodes |
X-Content-Type-Options: nosniff |
HTTP response header | Disables browser sniffing | Scoping to HTML responses only — must apply to all asset types |
type='video/mp4; codecs="av01.0.05M.08"' |
<source> attribute |
Declares codec profile to decoder | Wrong quantizer level in profile string causes canPlayType to return "" |
type='video/webm; codecs="vp9"' |
<source> attribute |
Declares VP9 container/codec | Omitting codec parameter — Safari may reject as unsupported even when WebM is available |
preload="metadata" |
<video> attribute |
Fetches duration/dimensions without full download | preload="auto" downloads the full video on page load — wasted bandwidth on mobile |
loading="lazy" |
<img> attribute |
Defers off-screen image fetch | Applying to LCP image — delays the critical image; use loading="eager" or omit for above-fold images |
decoding="async" |
<img> attribute |
Offloads image decode from main thread | No meaningful effect on loading="eager" images in the LCP path |
Accept-Ranges: bytes |
HTTP response header | Enables seeking and range requests on <video> |
Stripped by a transforming proxy — playback works but the scrub bar cannot seek |
Content-Type on 206 |
Partial-content response | Re-declares the type for each byte range | Omitted by range-serving proxies, breaking seek after the first range |
Content-Disposition: inline |
HTTP response header | Keeps media rendering in-page | attachment from a storage default turns every image into a download prompt |
Cross-Origin-Resource-Policy |
HTTP response header | Controls cross-origin embedding of the asset | same-origin on a CDN host blocks the very images it serves |
image/avif-sequence |
HTTP response header | Animated AVIF (ftypavis brand) |
Serving a sequence as image/avif — some decoders show only frame one |
Tradeoffs and Edge Cases
Tradeoff: JPEG XL delivery is Safari-only as of 2026. Chrome removed JPEG XL support in version 110 after a trial period. Firefox has not shipped it. Serving .jxl assets requires a robust <picture> fallback chain: JPEG XL → AVIF → WebP → JPEG. If your origin bucket stores only JPEG XL and JPEG, browsers without JXL support will fall through to JPEG even when WebP would offer better compression. Maintain all three next-gen variants in your encoding pipeline or use a CDN that transcodes on demand.
Tradeoff: Vary: Accept fragments CDN cache. Every distinct Accept header value creates a separate cache entry. Modern browsers send image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 — a long, non-normalized string. Cloudflare normalizes Accept to a small set of tiers before using it as a cache key; AWS CloudFront does not normalize by default. On CloudFront, use Lambda@Edge or CloudFront Functions to normalize the Accept header to a three-value key (avif, webp, or baseline) before caching.
Tradeoff: AV1 codec profile strings are version-specific. The codec string av01.0.05M.08 encodes profile 0, level 5, tier Main, 8-bit depth. A 10-bit HDR encode requires av01.0.05M.10. Serving the wrong codec string in Content-Type causes canPlayType to return "probably" on the wrong variant, leading to decoder failures on devices without 10-bit support.
Tradeoff: HLS vs DASH MIME types must match the player library. video.js and hls.js key on application/vnd.apple.mpegurl for HLS and application/dash+xml for DASH. If the origin returns text/plain or application/octet-stream for .m3u8 files (common with misconfigured S3 buckets), adaptive bitrate players silently refuse to load the manifest. This is one of the most common failures when debugging incorrect Content-Type headers for WebM videos.
Tradeoff: range requests and conditional responses can lose the type. A 206 Partial Content response must repeat the Content-Type of the full representation; a 304 Not Modified is allowed to omit it entirely, because the client is expected to reuse the stored headers. That asymmetry produces a nasty failure mode: fix a wrong Content-Type at the origin, and clients holding a cached copy keep revalidating with If-None-Match, get a 304, and continue using the old, wrong type from their cache. The fix requires changing the ETag (or the URL) so revalidation returns a full 200 with the corrected header. When a video is served through a range-serving proxy, verify the type on a partial fetch as well as a full one: curl -sI -H 'Range: bytes=0-1023' should return 206 with the same content-type as the unranged request.
Tradeoff: X-Content-Type-Options: nosniff blocks cross-origin script loads. When the header is set on a JavaScript file that is loaded cross-origin and the Content-Type is anything other than a JavaScript MIME type, the browser blocks execution. Audit all cross-origin asset loads before enabling nosniff sitewide.
Debugging and Validation
Almost every MIME failure in production reduces to one of four observable symptoms, and each has exactly one class of root cause. Start from what curl -sI prints rather than from the browser’s rendering, because the browser’s sniffing behaviour hides the difference between “the server declared the right type” and “the server declared nothing and Chrome guessed correctly”. The tree below maps each symptom to its cause and the fix that resolves it:
CLI: verify headers with curl
# Check AVIF Content-Type, Vary, and nosniff on a production CDN node
curl -sI \
-H 'Accept: image/avif,image/webp,*/*;q=0.8' \
https://cdn.your-domain.com/images/hero.avif \
| grep -iE 'content-type|vary|x-content-type|cache-status'
# Expected response headers:
# content-type: image/avif
# vary: Accept
# x-content-type-options: nosniff
# cache-status: HIT (or cf-cache-status: HIT on Cloudflare)
# Check a WebM video response
curl -sI https://cdn.your-domain.com/videos/clip.vp9.webm \
| grep -iE 'content-type|accept-ranges'
# Expected:
# content-type: video/webm
# accept-ranges: bytes ← required for <video> seeking / range requests
Browser DevTools: Network panel waterfall
Open DevTools → Network → filter by Img or Media. Select the asset row and inspect the Response Headers panel:
- Confirm
content-typematches the expected MIME type for the format that was served. - Confirm
vary: Acceptis present on image responses. - Check the Initiator tab — if the browser issued a second request for a fallback format, the first request returned a wrong or missing
Content-Type. - In the Timing tab, a non-zero Stalled duration combined with a
content-type: application/octet-streamresponse indicates the browser initiated and then aborted a decode attempt.
Lighthouse and PerformanceResourceTiming
// In the browser console or a Lighthouse custom metric script:
// Compare transferSize vs decodedBodySize for image resources.
// A ratio close to 1 with zero retries confirms optimal MIME routing.
performance.getEntriesByType('resource')
.filter(e => e.initiatorType === 'img' || e.initiatorType === 'video')
.forEach(e => {
const ratio = e.decodedBodySize / e.transferSize;
console.log(`${e.name}: transfer=${e.transferSize}B decoded=${e.decodedBodySize}B ratio=${ratio.toFixed(2)}`);
// ratio >> 1 = compressed (good); ratio ≈ 1 = uncompressed or wrong format served
});
Run a full Lighthouse audit to quantify the LCP and CLS impact of MIME configuration changes:
# Lighthouse CLI — mobile simulation, performance category only
lighthouse https://your-domain.com \
--only-categories=performance \
--throttling-method=devtools \
--output=json \
--output-path=./lh-report.json
# Extract LCP and CLS from the JSON report
node -e "
const r = require('./lh-report.json').audits;
console.log('LCP:', r['largest-contentful-paint'].displayValue);
console.log('CLS:', r['cumulative-layout-shift'].displayValue);
"
Monitor PerformanceResourceTiming.transferSize versus decodedBodySize in production via the Cache-Control and CDN caching documentation. A ratio near 1:1 with zero retried requests confirms correct MIME routing and efficient CDN cache hits.
Frequently Asked Questions
Does the browser ignore Content-Type for images, since it sniffs anyway?
No. Sniffing runs only when the declared type is absent, application/octet-stream, or text/plain, and X-Content-Type-Options: nosniff disables it outright. Media Source Extensions never sniff: addSourceBuffer() throws NotSupportedError on any MIME string it does not recognise.
Why does my AVIF work in Chrome but render blank in Safari?
Almost always a missing Vary: Accept. A shared edge cached the AVIF representation under a key that ignores Accept and then served it to a client that never advertised image/avif. Add Vary: Accept on negotiated routes and purge the poisoned entry.
What is the difference between hvc1 and hev1?
hvc1 carries HEVC parameter sets out of band in the sample description box; hev1 allows them in band. Safari and iOS require hvc1 for HLS fragmented MP4, so an otherwise valid hev1 string yields a black frame. Re-mux with ffmpeg -tag:v hvc1.
Does Content-Type need to be repeated on 206 Partial Content responses?
Yes. A single-range 206 must carry the same type as the full representation. Proxies that drop it break seeking in <video>, because the media pipeline re-evaluates the type on the first range it receives.
Should Vary: Accept be set on video and manifest responses? No. Accept does not drive video format selection — <source> ordering and the player do. Adding it to video, segment, and manifest responses fragments the CDN cache for no benefit.
Related
- Debugging incorrect Content-Type headers for WebM videos — step-by-step fix for the most common WebM and HLS manifest MIME failures
- Configuring the AVIF MIME type on Apache and Caddy — exact
AddTypeandheader/mimesnippets to stop.avifbeing served asapplication/octet-stream - Understanding video codecs: VP9 vs H.265 vs AV1 — codec profiles, bitrate-quality tradeoffs, and when each format is worth the encode cost
- AVIF vs WebP compression benchmarks — file-size and SSIM data to justify format choices in your MIME type delivery stack
- Cache-Control headers for image and video assets —
max-age,immutable, andstale-while-revalidatepatterns that build on correct MIME registration - Core Media Fundamentals & Next-Gen Formats — parent overview covering the full delivery pipeline from encoding to browser rendering