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.
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:
- Evaluate
sizesto compute the image’s layout width in CSS pixels. Ifsizesis absent, it defaults to100vw. - Multiply the layout width by
devicePixelRatioto get the required source width in physical pixels. - Walk the
srcsetdescriptor 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. - 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:
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:
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 son the 75th percentile across mobile connections. - CLS — target
< 0.1; any layout shift above 0.05 during image load indicates missingwidth/height. - INP —
< 200 ms; synchronous image decode on the main thread is a common contributor; ensuredecoding="async"on all images. - Cache hit rate — monitor CDN hit ratio per MIME type; a drop in AVIF hit rate often signals a
Vary: Acceptmisconfiguration 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.
Related
- Mastering srcset and sizes for Responsive Layouts — how the browser’s resource-selection algorithm works and how to author descriptor sets that minimise waste
- Art Direction with the HTML Picture Element — crop-level composition control and format negotiation via
<picture>/<source type> - CSS Container Queries for Dynamic Media Sizing — component-relative media sizing that decouples breakpoints from the viewport
- Responsive Video Delivery in Next.js and React — framework-native patterns for lazy video, poster optimisation, and playback state
- Cache-Control headers for image and video assets —
max-age,immutable, and theVary: Acceptrequirement for format-negotiated responses - Using fetchpriority to optimise critical media — elevating the LCP candidate’s fetch priority without starving other critical resources