Responsive Image & Video Delivery

Serving the right image or video — at the right resolution, in the right format, at the right moment — is one of the highest-leverage optimisations available to a frontend performance engineer. A single poorly-tuned hero image can push Largest Contentful Paint past the 2.5 s threshold on mid-range mobile devices; an over-sized background video on a 4G connection can consume 15× the bandwidth a compressed AV1 variant would. Responsive media delivery solves both problems through a layered system: browser-side negotiation via srcset and sizes, server-side format selection via Accept headers, pipeline automation through build-time encoding and CDN edge logic, and runtime prioritisation via fetchpriority and IntersectionObserver. Getting all four layers right reduces median LCP by 40–60 % and bandwidth cost by an equivalent margin without touching visual quality.


Responsive Media Delivery Pipeline A four-stage flow diagram showing how a source asset passes through build-time encoding (Sharp/FFmpeg), CDN edge caching with Accept-header negotiation, browser resource selection via srcset/sizes, and final render with layout stability checks. Source Asset Original JPEG/MP4 4 K / high-bitrate Build Pipeline Sharp (images) FFmpeg (video) → AVIF / WebP / JPEG → AV1 / VP9 / H.264 → width variants → srcset manifest CI/CD automated CDN Edge Accept header check Vary: Accept Cache-Control: max-age=31536000 immutable ~1 ms TTFB at PoP Browser Preload scanner srcset negotiation fetchpriority=high → LCP candidate IntersectionObserver → deferred assets width/height → no CLS 1× master N variants generated format served per client LCP < 2.5 s · CLS = 0

What this section covers

This section examines every layer of the responsive media stack. The four topics below map to the four decision surfaces an engineer actually encounters, in the order they usually bite: how many bytes to send, which composition to send, how the component knows its own width, and how a framework wires all of that into a build.

Mastering srcset and sizes for Responsive Layouts covers the browser’s resource-selection algorithm in detail — how it evaluates the srcset descriptor list against the computed sizes hint, when it ignores sizes entirely, and how to author descriptor sets that keep waste below 5 % across the real-world viewport distribution you care about. The companion page on calculating optimal sizes attribute values walks through the maths with concrete examples.

Art Direction with the HTML Picture Element steps beyond resolution switching into format negotiation and crop-level composition control. The <picture> element lets you serve a tightly-cropped portrait variant on narrow viewports and a wide-angle landscape on desktop — without any JavaScript — while the type attribute on each <source> drives the format fallback chain (AVIF → WebP → JPEG).

CSS Container Queries for Dynamic Media Sizing addresses the architectural limitation of viewport-relative sizes: in a component-driven codebase, a card component does not know how wide its container will be at render time. Container queries expose a cqi unit and @container rules that let the component author specify media dimensions relative to the component’s own inline size rather than the viewport, eliminating the need to thread breakpoint knowledge down through prop hierarchies.

Responsive Video Delivery in Next.js and React covers framework-specific patterns: the next/image component’s built-in srcset generation, lazy video initialisation with IntersectionObserver, poster optimisation to prevent layout shift during buffering, and playback-state management for background video loops. The detail pages on implementing responsive video with Video.js and using next/image with custom loader configurations extend this into third-party player integration and CDN-specific loader setup.


Core theory: how browsers negotiate format and resolution

The srcset resource selection algorithm

When a browser parses an <img srcset="...">, it does not simply pick the largest variant that fits. The selection algorithm, defined in the WHATWG HTML Living Standard, runs as follows:

  1. Evaluate sizes to compute the image’s layout width in CSS pixels. If sizes is absent, it defaults to 100vw.
  2. Multiply the layout width by devicePixelRatio to get the required source width in physical pixels.
  3. Walk the srcset descriptor list and select the narrowest candidate that is at least as wide as the required source width. If no candidate is wide enough, the widest one wins.
  4. Cache the chosen URL for the session. A browser will not downgrade to a narrower variant even if the viewport shrinks — the cached URL sticks until a full reload.

The critical implication of step 4 is that srcset only prevents over-fetching on first load. Users who open a page on a narrow viewport and then resize will not automatically re-fetch a wider variant; those who start on a wide viewport retain the wide asset even when they later narrow the window. This asymmetry shapes how you structure your descriptor set: prioritise the narrowest breakpoint rather than over-optimising for the widest.

Modelled as a state machine, the algorithm has exactly one absorbing state. Everything before it is cheap and re-runnable; everything after it is frozen until the document is torn down:

srcset Selection as a State Machine Six states connected by transitions: the parsed image element evaluates sizes to a layout width, multiplies by device pixel ratio, walks the descriptor list to pick a candidate, locks that URL for the session, and paints. A self-transition shows that viewport resize does not re-run selection; only a full reload returns the machine to its initial state. Idle img srcset parsed Layout width known sizes resolved to CSS px Target computed CSS px × DPR Candidate picked narrowest that fits URL locked absorbing state Painted no further switching sizes parsed DPR read walk the list fetch issued decoded and painted resize or DPR change: no re-selection only a full navigation resets the machine

Descriptor types: w, x, and why they cannot be mixed

A srcset list is parsed into candidates that each carry exactly one descriptor, and the HTML parser refuses to mix kinds. If any candidate in a list carries a w descriptor, every candidate must; a stray 2x in a w list makes the whole srcset invalid and the browser falls back to src. This failure is silent — there is no console warning in Chrome or Firefox, and the page still renders, so the regression only surfaces as a bandwidth number that stops improving.

The two descriptor kinds also differ in how they interact with sizes. A w list requires sizes to be meaningful, because w describes the intrinsic pixel width of the file and the browser needs a layout width to compare it against. An x list describes density directly, so sizes is ignored entirely when it is present alongside x candidates. That makes x the right choice for fixed-dimension assets — logos, avatars, icons rendered at a CSS size you control — and w the right choice for anything whose rendered width depends on the layout. A candidate with no descriptor at all is treated as 1x, which is why srcset="a.jpg, b.jpg 2x" works and srcset="a.jpg, b.jpg 800w" does not.

Selection against an x list is a straight nearest-match against devicePixelRatio, but note that the ratio is rarely an integer in the field: the Pixel 8 reports 2.625, many mid-range Android devices report 2.75, and Windows display scaling produces values like 1.25 and 1.5. A two-entry 1x, 2x list therefore serves the 2x file to a very large share of traffic. Adding a 3x entry is usually wasted bytes — the perceptual return above roughly 2.5× is negligible on displays under 7 inches — while adding a 1.5x entry measurably reduces waste for Windows laptops.

When the preload scanner reads sizes — and what sizes="auto" changes

The preload scanner runs on the raw token stream before style is resolved and before layout has produced any box. It therefore has no way to know how wide the image will actually be, which is the entire reason sizes exists: it is a promise the author makes about a width that has not been computed yet. Media conditions inside sizes are evaluated against the viewport, so they work at scan time; anything that would require layout — a percentage of a parent, a flex distribution, a grid track — cannot be expressed and must be approximated.

Chrome 129 and Firefox 132 relaxed this with sizes="auto", valid only on an <img> that also carries loading="lazy". Because a lazy image is by definition not fetched until it approaches the viewport, layout has already run by the time selection happens, and the browser can substitute the element’s real concrete width for the author’s guess. The rule to internalise is that sizes="auto" and fetchpriority="high" are mutually exclusive in practice: the moment an image matters enough to prioritise, it cannot be lazy, and the moment it cannot be lazy, auto is not available. Every above-the-fold image still needs a hand-authored sizes, and the arithmetic for that is worked through in how to calculate optimal sizes attribute values.

Warning: a sizes value that overstates the rendered width is the single most common cause of image over-fetch. The browser trusts it unconditionally — there is no correction step after layout for eagerly loaded images — so sizes="100vw" on an image that renders in a 400 px column on a 1440 px desktop causes a 3.6× over-fetch that no amount of format optimisation recovers.

Format negotiation via Accept headers

The <picture> / <source type="..."> API is client-driven: the browser picks the first <source> whose type MIME type it supports. This is simple and reliable, but it has one drawback — the browser must parse and evaluate the entire <picture> element before making a network request, adding a small parse cost relative to a bare <img>.

The server-driven alternative is Accept header negotiation. The browser sends Accept: image/avif,image/webp,*/* (Chrome, as of early 2025), and an edge worker or Nginx map directive inspects this header and rewrites the image URL to the appropriate format before fetching from origin. This approach serves a single <img src> tag and handles fallback silently, but it requires correct Cache-Control headers — specifically Vary: Accept on every response so CDN caches maintain separate entries per format. Omitting Vary: Accept causes AVIF responses to be served to browsers that requested JPEG, breaking the image for Safari 14.

Client Hints: the third negotiation channel

Accept negotiates format; Client Hints negotiate dimension. An origin that sends Accept-CH: Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-Width opts the browser into attaching those values to subsequent image requests, at which point the edge can resize on the fly and the markup collapses to a single <img src> with no srcset at all. Sec-CH-Width is the most useful of the three because it reports the layout width in physical pixels — the number sizes was trying to predict — computed after layout rather than guessed before it.

Three mechanics decide whether this is workable in production. First, Accept-CH is delivered on a response, so the very first image request of a fresh session never carries the hints; the edge must have a sane default. Second, Critical-CH promotes a hint to blocking status, causing the browser to discard the connection and retry the navigation once, which costs a full round trip and is almost never worth it for images. Third, every hint you accept becomes a cache dimension: Vary: Sec-CH-Width with an unbounded value space fragments the edge cache into hundreds of near-identical objects. Quantise the width server-side into a fixed ladder — round up to the next 100 px — before it reaches the cache key. The CDN-specific mechanics live under CDN and edge media delivery, and the Vary cache-key trap is covered directly in the CloudFront cache policy for Vary: Accept negotiation.

Tradeoff: Client Hints remove srcset authoring entirely but concentrate risk at the edge. Safari does not send Sec-CH-Width at all, so a hints-only pipeline needs a srcset fallback for roughly a fifth of global traffic — which means maintaining both systems rather than one. Hints pay off when the image count per page is high and the layouts are numerous; srcset pays off when the templates are few and stable.

Chroma subsampling and perceptual quality

AVIF — derived from the AV1 video codec — stores colour using the YCbCr model and typically applies 4:2:0 chroma subsampling: full luma resolution, half horizontal and vertical chroma resolution. For photographic content, 4:2:0 is invisible at normal viewing distances. For text overlays, UI screenshots, and flat-colour graphics, 4:2:0 introduces visible colour fringing. These assets should be encoded with 4:4:4 subsampling (avifenc --yuv 444) at the cost of a 15–25 % larger file.

WebP uses a similar YCbCr scheme with a fixed 4:2:0 subsampling for the lossy path and 4:4:4 for lossless. JPEG offers encoder-level control: mozjpeg -sample 1x1,1x1,1x1 forces 4:4:4. PNG is always full-colour (no subsampling) and is the correct fallback for assets where chroma accuracy is non-negotiable.

Video codec selection mechanics

Codec selection for video follows an identical <source type> pattern. The browser evaluates each <source> in order and picks the first type it can decode in hardware. The negotiation is purely type-based — there is no equivalent of the image sizes hint for video bandwidth. This means the server must pre-generate multiple bitrate variants (typically three) and use a manifest-based adaptive streaming protocol (HLS or MPEG-DASH) if the video is longer than approximately 30 seconds. For short background loops, static multi-source <video> is sufficient.

Understanding VP9, H.265, and AV1 codec characteristics covers encode-time cost, decode hardware support, and compression efficiency in detail. The short version for production decisions: ship AV1 as the first <source> for maximum compression, VP9 as the second for broad Android and desktop Chrome coverage, and H.264 (baseline or main profile, +faststart) as the final fallback for Safari 14 and older Android WebView.

One refinement matters enough to state explicitly: type="video/mp4" alone is not a decodability test. A browser answers “yes, I can play MP4” and then discovers it cannot decode the AV1 bitstream inside it. Always include the full codecs parameter — type='video/mp4; codecs="av01.0.05M.08"' — so the type check is evaluated against the actual bitstream profile, level, and bit depth rather than the container. The same applies to WebM: codecs="vp09.00.10.08" distinguishes VP9 Profile 0 8-bit from Profile 2 10-bit, which many older Android SoCs cannot decode in hardware even though they advertise VP9 support. Device-level detail is catalogued in hardware AV1 decode support by device class.

The multi-source versus adaptive-streaming threshold

Static <source> lists and manifest-driven adaptive streaming solve different problems, and the boundary between them is a function of duration and abandonment risk rather than taste. A static list commits the client to one bitrate for the whole file: the browser picks a codec, starts a range request, and has no mechanism to switch if throughput collapses mid-playback. That is acceptable when the total payload is small enough that a stall is survivable.

The practical decision rule is a byte budget, not a stopwatch. Below roughly 3 MB per variant, static multi-source is the right choice — it costs no manifest, no player JavaScript, and no extra round trips, and the whole file is usually buffered before the user notices anything. Above that, the cost of guessing wrong grows faster than the cost of a manifest, and you should generate an HLS or DASH ladder. For a 1080p background loop at CRF 30 SVT-AV1, 3 MB corresponds to roughly 30–40 seconds, which is where the commonly quoted “30 second” rule comes from.

When you do build a ladder, three parameters dominate delivery behaviour. Segment duration sets the switching granularity and the minimum startup latency: 2 s segments allow fast adaptation at the cost of more requests and more per-segment overhead, 6 s segments halve the request count but leave the player stuck on a bad rendition for longer. The rung spacing controls how smoothly the player can climb; a ladder whose neighbouring rungs differ by more than about 1.8× forces visible quality jumps, while spacing under 1.4× wastes storage on renditions the player rarely selects. And the bottom rung determines who can watch at all — a ladder starting at 800 kbps simply fails on congested mobile networks, so include a 300–400 kbps rung even though it looks poor in a side-by-side comparison. Because every rung is an independently cacheable object, ladder design is also a cache-efficiency decision: doubling the rung count halves the hit rate per object at the edge, which is why stale-while-revalidate on media assets matters more for video than for images.


Reference data: format and codec comparison

The table below reflects encode benchmarks on a standard 2 MP photographic test set (Kodak dataset, 24 images) and a 30 s 1080p video clip encoded at target VMAF 93.

Format / Codec Median file size vs JPEG/H.264 baseline SSIM at matched quality Hardware decode support Encode time (per image / per minute of video)
JPEG (baseline) 100 % (reference) 0.93 Universal ~20 ms / —
WebP lossy (q 80) −28 % 0.95 CPU-only on older SoCs ~45 ms / —
AVIF (crf 32, 4:2:0) −48 % 0.95 HW decode: Chrome 85+, Safari 16+, iOS 16+ ~380 ms / —
AVIF (crf 32, 4:4:4) −33 % 0.96 same as above ~460 ms / —
H.264 (crf 23) 100 % (reference) Universal — / ~2× real-time
VP9 (crf 33) −34 % HW: Snapdragon 855+, most x86 — / ~6× real-time
AV1 / SVT-AV1 (crf 30) −47 % HW: Apple M1/M2, newer Android, Chrome 85+ on supporting SoCs — / ~3× real-time (SVT preset 6)

Key observation: AVIF’s 48 % size saving over JPEG is the largest available for raster images with equivalent perceived quality. The encoding penalty (≈19× slower than JPEG) is paid at build time or in an edge worker, not by the user. AVIF should be the first <source> for all photographic content on sites targeting Chrome 85+ and Safari 16+.

Plotted against their respective baselines, the same numbers show how close the image and video ladders have converged — the modern codec in each column lands near half the reference payload, and the second-tier fallback lands near two-thirds:

Median Transfer Size Relative to Baseline A horizontal bar chart in two panels. The image panel shows JPEG at 100, WebP q80 at 72, AVIF 4:4:4 at 67 and AVIF 4:2:0 at 52. The video panel shows H.264 at 100, VP9 at 66 and AV1 at 53. All values are percentages of the matched-quality baseline from the reference table above. Median transfer size at matched quality, baseline = 100 Raster images, vs JPEG JPEG q85 (baseline) 100 WebP q80 72 (−28 %) AVIF crf 32, 4:4:4 67 (−33 %) AVIF crf 32, 4:2:0 52 (−48 %) Video, vs H.264 H.264 crf 23 (baseline) 100 VP9 crf 33 66 (−34 %) SVT-AV1 crf 30 53 (−47 %) 0 25 50 75 100 Percent of baseline transfer size. Images: Kodak 24-image set. Video: 30 s 1080p clip at target VMAF 93.

Read the chart as two independent ladders rather than one: the image baseline is a 2 MP still and the video baseline is a 30 s clip, so the bars are only comparable within a panel. What transfers between them is the shape — a roughly 50 % saving from the newest codec, a roughly 30 % saving from the mid-tier one, and a rarely-requested tail of devices that can only decode the baseline. That shape is why a three-entry fallback chain is the stable answer in both media types, and why a two-entry chain (newest plus baseline, skipping the middle) leaves 20–30 % on the table for the substantial population that has WebP or VP9 hardware but not AVIF or AV1.

Browser and CDN compatibility matrix

Feature Safari 14 Safari 16 Chrome 85+ Firefox 93+ Edge 18+ Cloudflare Fastly AWS CloudFront
srcset w descriptor Yes Yes Yes Yes Yes Pass-through Pass-through Pass-through
<picture> / <source type> Yes Yes Yes Yes Yes Pass-through Pass-through Pass-through
AVIF decode No Yes Yes (85+) Yes (93+) Yes (94+) Image Resizing No native No native
WebP decode Yes (14+) Yes Yes Yes Yes (18+) Image Resizing Partial VCL Lambda@Edge
fetchpriority attribute No Yes (16.4+) Yes (101+) Yes (132+) Yes (101+)
CSS @container queries No Yes (16+) Yes (105+) Yes (110+) Yes (105+)
Native loading="lazy" No (14) Yes (15.4+) Yes (77+) Yes (75+) Yes (79+)
AV1 video decode No No Yes (85+, desktop) Yes (93+) Yes (94+)
VP9 video decode Partial Yes (16+) Yes Yes Yes
sizes="auto" on lazy <img> No No Yes (129+) Yes (132+) Yes (129+)
Sec-CH-Width client hint No No Yes (67+) No Yes (79+) Worker-readable VCL-readable Origin-request only
<source> width/height mapping No Yes (16.4+) Yes (108+) Yes (108+) Yes (108+)
JPEG XL decode No Yes (17+) No Behind flag No Pass-through Pass-through Pass-through
AVIF animation / sequences No Yes (16+) Yes (94+) Yes (113+) Yes (94+) Partial

Safari 14 notes: No AVIF, no native lazy loading, no fetchpriority. Serve WebP via <picture> fallback; use a JavaScript IntersectionObserver polyfill for lazy loading; omit fetchpriority (the attribute is safely ignored, but the element will not receive elevated priority in Blink’s resource scheduler on this engine).


Canonical code pattern: production <picture> with full fallback chain

<!--
  Production-ready responsive image with format fallback and layout stability.
  Place this pattern for every LCP candidate (hero images, above-fold cards).
-->
<picture>
  <!--
    AVIF source: best compression, ~48 % smaller than JPEG at matched quality.
    srcset uses w-descriptors so the browser can pick the optimal width variant.
    sizes tells the browser the rendered width at each breakpoint BEFORE layout
    is computed — this is the only hint the preload scanner reads.
    Omitting sizes defaults to 100vw, causing the browser to over-fetch
    on multi-column layouts.
  -->
  <source
    type="image/avif"
    srcset="
      /media/hero-400.avif   400w,
      /media/hero-800.avif   800w,
      /media/hero-1200.avif 1200w,
      /media/hero-1600.avif 1600w
    "
    sizes="
      (max-width: 600px)  100vw,
      (max-width: 1200px) 50vw,
      800px
    "
  >
  <!--
    WebP fallback: covers Safari 14+, older Chrome/Firefox.
    Must duplicate the srcset — browsers evaluate <source> elements top-to-bottom
    and stop at the first type match; they do NOT fall back within a type.
  -->
  <source
    type="image/webp"
    srcset="
      /media/hero-400.webp   400w,
      /media/hero-800.webp   800w,
      /media/hero-1200.webp 1200w,
      /media/hero-1600.webp 1600w
    "
    sizes="
      (max-width: 600px)  100vw,
      (max-width: 1200px) 50vw,
      800px
    "
  >
  <!--
    JPEG ultimate fallback: always present, covers IE 11, older Safari, crawlers.
    width + height are MANDATORY — they establish the aspect ratio before the
    image loads, preventing CLS. Calculate as (rendered CSS px) * device ratio.
    loading="eager" + fetchpriority="high" puts this in the highest-priority
    queue in Blink. Use ONLY on the primary LCP element; applying fetchpriority=high
    to multiple images starves CSS and other critical resources.
    decoding="async" off-loads decode to a worker thread so it does not block
    main-thread painting — safe for all images including LCP.
  -->
  <img
    src="/media/hero-1200.jpg"
    alt="Dashboard analytics visualization showing real-time media delivery metrics"
    width="1200"
    height="630"
    loading="eager"
    fetchpriority="high"
    decoding="async"
  >
</picture>

Pipeline integration

Build-time image encoding with Sharp

Sharp wraps libvips and runs in a Node.js CI step. The key pipeline concern is generating a consistent srcset manifest alongside the variant files so the HTML can reference the correct filenames.

// scripts/generate-image-variants.mjs
// Run in CI before the 11ty/Next.js build step.
import sharp from 'sharp';
import { writeFileSync } from 'fs';

const WIDTHS = [400, 800, 1200, 1600];
const FORMATS = [
  {
    ext: 'avif',
    opts: {
      quality: 60,       // AVIF quality scale is 0–100 (higher = better quality, larger file)
      effort: 4,         // Encode effort 0–9; 4 balances CI time vs compression (default is 6)
      chromaSubsampling: '4:2:0'  // Use '4:4:4' for UI screenshots with text overlays
    }
  },
  {
    ext: 'webp',
    opts: {
      quality: 82,       // WebP quality 82 ≈ JPEG quality 85 perceptually
      effort: 4,
      smartSubsample: true  // Preserves colour accuracy near hard edges
    }
  },
  {
    ext: 'jpg',
    opts: {
      quality: 85,
      progressive: true,  // Progressive JPEG decodes top-down, improving perceived speed
      mozjpeg: true        // Enables mozjpeg encoder; ~5-10 % smaller than libjpeg at same quality
    }
  }
];

const srcImages = ['src/images/hero.jpg', 'src/images/team.jpg'];

for (const src of srcImages) {
  const base = src.replace(/^src\/images\//, '').replace(/\.\w+$/, '');
  const pipeline = sharp(src);
  for (const width of WIDTHS) {
    for (const { ext, opts } of FORMATS) {
      await pipeline
        .clone()
        .resize(width)
        .toFormat(ext, opts)
        .toFile(`public/media/${base}-${width}.${ext}`);
    }
  }
}

Build-time video encoding with FFmpeg

The two-pass VP9 + AV1 pipeline below covers the full codec fallback chain. The H.264 variant is always the final fallback — encode it last so its simpler parameter set does not accidentally become the template for the more complex VP9/AV1 commands.

#!/usr/bin/env bash
# encode-video-variants.sh
# Produces three codec variants for a short background loop (< 60 s).
# For longer content, generate multi-bitrate HLS with ffmpeg -hls_segment_type fmp4.

INPUT="$1"
BASE="${INPUT%.*}"

# --- AV1 via SVT-AV1 (libsvtav1) ---
# preset 6: fast enough for CI; preset 4–5 for maximum compression offline.
# crf 30: target quality (lower = higher quality, larger file; range 0–63 for SVT-AV1).
# Note: libsvtav1 does NOT support WebM container — output to MP4.
ffmpeg -i "$INPUT" \
  -c:v libsvtav1 -preset 6 -crf 30 \
  -c:a libopus -b:a 96k \
  -movflags +faststart \   # Move moov atom to file head for progressive play
  "${BASE}_av1.mp4"

# --- VP9 (two-pass for accurate target bitrate) ---
# two-pass VP9 produces ~15 % smaller files than CRF-only for a given quality target.
ffmpeg -i "$INPUT" \
  -c:v libvpx-vp9 -b:v 0 -crf 33 \
  -pass 1 -an -f null /dev/null && \
ffmpeg -i "$INPUT" \
  -c:v libvpx-vp9 -b:v 0 -crf 33 \
  -c:a libopus -b:a 96k \
  -pass 2 \
  "${BASE}_vp9.webm"

# --- H.264 (single-pass CRF, faststart for web) ---
# -preset medium balances encode speed vs compression efficiency.
# -profile:v main covers all modern devices; change to baseline only for legacy Android 4.x.
ffmpeg -i "$INPUT" \
  -c:v libx264 -preset medium -crf 23 \
  -profile:v main -level 4.0 \
  -c:a aac -b:a 128k \
  -movflags +faststart \
  "${BASE}_h264.mp4"

Edge-side format negotiation (Nginx)

For origin servers that prefer server-driven negotiation over <picture> markup, the following Nginx map block routes requests to the correct format based on the Accept header. This requires correctly configured Vary: Accept response headers — without them, a CDN in front will serve a cached AVIF response to a browser that only sent image/jpeg in its Accept header.

# nginx.conf — image format negotiation via Accept header
# Requires: pre-generated AVIF and WebP variants at the same path with .avif / .webp suffix.

http {
  # Map Accept header to best supported format
  # Order matters: check AVIF first, then WebP, then default to JPEG.
  map $http_accept $webp_suffix {
    default        "";
    "~*image/avif" ".avif";  # Chrome 85+, Firefox 93+, Safari 16+
    "~*image/webp" ".webp";  # Safari 14+, Chrome, Firefox, Edge
  }

  server {
    location ~* \.(jpe?g|png)$ {
      # Try the format-suffixed path first; fall back to the original.
      # $uri$webp_suffix resolves to e.g. /media/hero.jpg.avif when AVIF is accepted.
      try_files $uri$webp_suffix $uri =404;

      # CRITICAL: Vary: Accept is mandatory.
      # Without it, a CDN caches the first response (AVIF or WebP) and serves it
      # to ALL clients regardless of their Accept header — breaking older browsers.
      add_header Vary Accept;

      # Long cache lifetime is safe because filenames are content-hashed.
      # Use a shorter max-age (e.g. 86400) if files are not content-hashed.
      add_header Cache-Control "public, max-age=31536000, immutable";
    }
  }
}

Tradeoffs and failure modes

Failure mode Trigger condition Mitigation
CDN cache poisoning via Vary: Accept Vary: Accept header missing on image responses Always set Vary: Accept; verify with curl -sI inspecting the response headers
AVIF encode time blows CI budget Large image set, effort ≥ 6 Reduce effort to 4; run encoding in a dedicated parallel step; cache build artefacts between CI runs
fetchpriority=high starvation Applied to more than one image per page Reserve fetchpriority=high for exactly the single LCP candidate; all other images use the default
IntersectionObserver rootMargin over-eager Large positive rootMargin on fast scrollers Start at '200px'; measure buffering events in RUM; tighten to '50px' if bandwidth is constrained
AVIF served to Safari 14 (no <picture>) Server-side negotiation without Accept check Safari 14 sends Accept: image/webp,*/* — never image/avif; the Nginx map above handles this correctly
loading="lazy" ignored on Safari 14 Native lazy loading unsupported Detect support with 'loading' in HTMLImageElement.prototype; fall back to IntersectionObserver — see advanced IntersectionObserver patterns
CLS from missing width/height Dimensions omitted from <img> Always set width and height attributes matching the largest rendered size; let CSS max-width: 100% handle responsiveness
@container query fallback gap Container queries unsupported (pre-Chrome 105) Write a baseline viewport media query first; @container will override it in supporting browsers via the cascade
VP9 hardware decode absent on mid-range Android Devices pre-Snapdragon 855 Include H.264 as the final <source> fallback; test on Moto G series in BrowserStack
Whole srcset ignored, src served instead A single x descriptor mixed into a w list (or vice versa) Generate descriptors programmatically from one source of truth; assert in CI that every candidate in a list ends with the same descriptor suffix
sizes overstates rendered width sizes="100vw" copy-pasted onto a multi-column card grid Derive sizes from the same breakpoint tokens the CSS uses; measure img.currentSrc against img.getBoundingClientRect().width × DPR in a synthetic check
Edge cache fragmented into near-duplicate objects Vary on an unquantised client hint such as Sec-CH-Width Round the hint up to a fixed 100 px ladder before it reaches the cache key; keep the raw value out of Vary
type="video/mp4" passes but playback fails Codecs parameter omitted, AV1 bitstream inside an MP4 container Always declare the full codecs= string so the type check tests the bitstream profile, not the container
Adaptive player pinned to the lowest rung Bottom of the bitrate ladder too far below the next rung Keep neighbouring rungs within a 1.4×–1.8× ratio so the player can climb in usable steps

Debugging and performance telemetry

Identifying the LCP element

Open Chrome DevTools, run a Lighthouse audit, and expand the “Largest Contentful Paint” opportunity. The element path will confirm whether the LCP candidate is your intended hero <img> or something else (a background <div>, a <video> poster, etc.). If the LCP element is a CSS background image, fetchpriority and <link rel="preload"> cannot help directly — the asset only becomes discoverable after CSSOM construction. Convert it to an inline <img> or add an explicit <link rel="preload" as="image" fetchpriority="high"> in <head>. For more on prioritising critical assets, see using fetchpriority to optimise critical media.

Verifying format negotiation

# Confirm AVIF is being served to a Chrome-like Accept header
curl -sI -H "Accept: image/avif,image/webp,*/*" https://example.com/media/hero.jpg \
  | grep -E "content-type|vary|cache-control"

# Expected output (server-side negotiation):
# content-type: image/avif
# vary: Accept
# cache-control: public, max-age=31536000, immutable

# Confirm JPEG fallback for Safari 14
curl -sI -H "Accept: image/webp,*/*" https://example.com/media/hero.jpg \
  | grep content-type
# Expected: content-type: image/jpeg  (or image/webp if WebP fallback is served)

RUM telemetry targets

Instrument your RUM pipeline (web-vitals.js or equivalent) to capture:

  • LCP — target < 2.5 s on the 75th percentile across mobile connections.
  • CLS — target < 0.1; any layout shift above 0.05 during image load indicates missing width/height.
  • INP< 200 ms; synchronous image decode on the main thread is a common contributor; ensure decoding="async" on all images.
  • Cache hit rate — monitor CDN hit ratio per MIME type; a drop in AVIF hit rate often signals a Vary: Accept misconfiguration or a deploy that cleared the format-keyed cache entries.

When LCP exceeds 2.5 s at the 75th percentile, the most common causes in order of frequency are: no fetchpriority=high on the LCP image, the LCP image discovered late (CSS background, dynamically injected <img>), AVIF encode quality set too low causing visible decode artefacts that trigger a retry, and origin TTFB exceeding 600 ms due to absent CDN caching.

Measuring selection waste directly

Lab metrics tell you how fast the wrong image arrived; they do not tell you that it was the wrong image. The cheapest direct measure of srcset accuracy is the ratio of transferred pixels to painted pixels, sampled in the field:

// Report over-fetch ratio per image after load. A ratio above ~1.3 means the
// chosen candidate is materially wider than the box it paints into — almost
// always a sizes bug rather than a descriptor bug.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType !== 'img') continue;
    const el = document.querySelector(`img[src$="${entry.name.split('/').pop()}"]`);
    if (!el || !el.naturalWidth) continue;

    // naturalWidth is the decoded intrinsic width of the candidate the
    // browser actually selected — NOT what the srcset descriptor claimed.
    const paintedPx = el.getBoundingClientRect().width * devicePixelRatio;
    const ratio = el.naturalWidth / paintedPx;

    if (ratio > 1.3) {
      // Ship to RUM with currentSrc so the offending candidate is identifiable.
      reportWaste({ src: el.currentSrc, ratio: +ratio.toFixed(2), bytes: entry.transferSize });
    }
  }
}).observe({ type: 'resource', buffered: true });   // buffered:true catches images that loaded before this ran

Aggregate the result as total wasted bytes per session rather than as a mean ratio — one 1.6 MB hero at ratio 2.0 outweighs forty thumbnails at ratio 1.4, and a mean hides that completely. Cross-reference the worst offenders against field LCP from the CrUX API before spending encode effort: an image with a high waste ratio that never becomes the LCP element is a bandwidth problem, not a Core Web Vitals problem, and it should be prioritised accordingly. Build-time enforcement of the same budget belongs in your framework and build-tool media integration layer, where the descriptor set is generated and can be validated against the templates that consume it.