Mastering srcset and sizes for Responsive Layouts

srcset and sizes are the browser’s primary negotiation interface for resolution switching — the mechanism by which the client selects an appropriately-sized image candidate without a round-trip request. Within Responsive Image & Video Delivery, these two attributes sit at the foundation: getting them right eliminates over-fetching on mobile, prevents layout shift before paint, and guarantees the browser preloader can act during the initial HTML parse before any JavaScript or CSS has executed.

The preloader reads srcset width descriptors and sizes media conditions during tokenisation, computes an effective viewport width, and emits a preload request for the best-fit candidate — typically 40–80 ms before DOMContentLoaded. Any miscalculation in sizes causes the preloader to select the wrong candidate, wasting bandwidth or degrading visual quality. The stakes are direct: LCP images under a misspecified sizes value can arrive 30–60% larger than the rendered slot demands.


Concept & Architecture: How the Browser Selects a Candidate

The browser’s image-selection algorithm is specified in the WHATWG HTML Living Standard §4.8.4.3. Understanding each step explains why guessing sizes is never acceptable on production pages.

The selection pipeline

Browser srcset / sizes selection pipeline Five stages: HTML tokeniser parses srcset and sizes; preloader evaluates sizes media conditions against viewport; computes effective slot width; multiplies by device pixel ratio; emits network request for the closest matching width descriptor. HTML Tokeniser parses srcset + sizes Sizes Evaluator matches breakpoint → slot width (CSS px) DPR Multiply slot × devicePixelRatio → required px Candidate Match closest ≥ required from srcset list Preload Request ~40 ms pre-DOMCl All five stages complete before layout — no JS or CSS required Input HTML src / srcset window.devicePixelRatio Safari rounds fractional DPR (e.g. 2.625 → 3.0) — always provide discrete 1×/2×/3× buckets

The four spec algorithms behind two attributes

What the diagram compresses into “Sizes Evaluator” is actually four separate algorithms that run in a fixed order. Knowing their boundaries tells you exactly which mistakes are silently survivable and which ones throw the whole source set away.

1. Update the source set. When the element is an <img> inside a <picture>, the browser walks the preceding <source> siblings in document order. The first one whose type is a format the engine can decode and whose media condition matches supplies both the candidate list and the sizes list. If no <source> qualifies, the <img> element’s own srcset and sizes are used. Note the consequence: a sizes attribute on the <img> is ignored entirely when a <source> wins, which is why every <source> needs its own copy.

2. Parse a srcset attribute. The attribute is split on commas, and each entry becomes a URL plus an optional descriptor. Two descriptor families exist — w (width in pixels of the resource) and x (pixel density) — and a source set may not mix them. A repeated descriptor value, a w descriptor of zero, a negative density, or a w and an x descriptor in the same list is a parse error: the offending candidates are dropped, and if nothing survives the browser falls back to src. This failure is completely silent in the console, so it is worth asserting on img.currentSrc in tests.

3. Parse a sizes attribute. The result is an ordered list of (media condition, source size value) pairs, with at most one unconditional value which must appear last. A source size value must be a CSS <length>640px, 50vw, calc(50vw - 24px), min(100vw, 960px) are all legal; a bare percentage is not, because there is no containing block to resolve it against at preload time. Font-relative units resolve against the initial font size, not the element’s computed one, so 20em in sizes almost never means what a stylesheet author expects. If the attribute is absent, or no condition matches and there is no unconditional entry, the effective size defaults to 100vw.

4. Select an image source. The browser converts every candidate to a normalized density: for a w descriptor that is descriptor ÷ source-size-in-CSS-px; for an x descriptor it is the descriptor itself. It then picks the smallest normalized density that is greater than or equal to the device pixel ratio, and if no candidate reaches the DPR it takes the largest available.

Work an example through it. Take srcset="hero-480.webp 480w, hero-960.webp 960w, hero-1440.webp 1440w, hero-2160.webp 2160w" with a sizes value that resolves to a 427 px slot — a three-column grid on a 1280 px viewport:

Candidate Descriptor Normalized density at a 427 px slot Chosen at DPR 1 DPR 2 DPR 3
hero-480.webp 480w 480 ÷ 427 = 1.124× selected
hero-960.webp 960w 960 ÷ 427 = 2.248× selected
hero-1440.webp 1440w 1440 ÷ 427 = 3.373× selected
hero-2160.webp 2160w 2160 ÷ 427 = 5.059×

The 2160w file is never selected at this slot width — it only becomes reachable when sizes resolves above ~720 px. That is the practical test for whether a candidate earns its place in the build: if no combination of your sizes breakpoints and the DPRs in your analytics can select it, delete it.

Two engine-specific deviations matter in production. Chrome and Firefox apply a “never downgrade” heuristic: if a larger candidate for the same element is already in the HTTP cache, they use it rather than issue a request for a smaller one. And Chrome’s preloader will not re-run selection when a stylesheet later changes the layout — the candidate chosen during tokenisation is final unless script mutates srcset, sizes, or src.

Width descriptors vs density descriptors

srcset supports two descriptor syntaxes. Width descriptors (400w, 800w) are almost always the right choice for responsive images — they give the browser the physical pixel dimensions so it can apply its own DPR arithmetic. Density descriptors (1x, 2x) are appropriate only for fixed-size UI elements (icons, logos) where the rendered width never changes.

<!-- Width descriptors: browser picks based on layout slot + DPR -->
<img srcset="/img/hero-480.webp 480w,
             /img/hero-960.webp 960w,
             /img/hero-1440.webp 1440w,
             /img/hero-2160.webp 2160w"
     sizes="(max-width: 640px)  100vw,
            (max-width: 1024px) 50vw,
            33vw"
     src="/img/hero-960.webp"
     width="1440" height="960"
     alt="Hero: responsive delivery pipeline overview"
     loading="eager"
     fetchpriority="high">

<!-- Density descriptors: fixed-slot UI element only -->
<img srcset="/img/logo.webp 1x,
             /img/[email protected] 2x"
     src="/img/logo.webp"
     width="120" height="40"
     alt="Brand logo">

Choosing a descriptor strategy

Four markup shapes cover essentially every image on a production page, and the choice between them is mechanical once you can answer three questions about the element: does its rendered width move, does its composition change, and is it fetched eagerly or lazily. The tree below is the version worth pinning to a component-library README.

Descriptor strategy decision tree Three sequential questions. If the rendered width never varies, use density descriptors and omit sizes. If the crop changes between breakpoints, use picture with media on each source. If the image is lazily loaded below the fold, sizes auto is available. Otherwise use width descriptors with an explicit sizes list. Does the rendered width change with viewport or container? no Fixed slot: logo, avatar, icon → x descriptors, omit sizes yes Does the crop or subject framing change between breakpoints? yes → <picture> with media on each <source>; every source carries its own srcset and sizes no Below the fold with loading="lazy"? yes → sizes="auto" where supported, with a written sizes list as the fallback for older engines no → w descriptors plus an explicit sizes list mirroring the CSS breakpoints that set the slot Every branch still needs width and height attributes so the layout slot is reserved before the bytes arrive

Warning: The one combination with no correct answer is an eagerly fetched image whose width is set by a container rather than the viewport. The preloader cannot know the container size and sizes="auto" requires lazy loading, so you are left estimating — see CSS container queries for dynamic media sizing for the arithmetic that keeps the estimate honest.


Benchmark Data: File-Size Impact of Accurate vs Inaccurate sizes

The table below shows a 1440 × 960 px source image at various rendered slots. “Accurate” means sizes matches the actual CSS layout width; “Default (100vw)” means the sizes attribute is omitted (browser assumes full viewport width).

Viewport Rendered slot DPR Accurate sizes candidate Default (100vw) candidate Waste
375 px mobile 375 px 2 960w (≈ 38 KB WebP) 960w (≈ 38 KB WebP) 0%
375 px mobile 187 px (50vw) 2 480w (≈ 14 KB WebP) 960w (≈ 38 KB WebP) +171%
768 px tablet 256 px (33vw) 2 480w (≈ 14 KB WebP) 1440w (≈ 74 KB WebP) +429%
1280 px desktop 427 px (33vw) 1 480w (≈ 14 KB WebP) 1440w (≈ 74 KB WebP) +429%
1440 px desktop 720 px (50vw) 1 960w (≈ 38 KB WebP) 1440w (≈ 74 KB WebP) +95%

Charted, the shape of that waste is unintuitive: it is worst in the middle of the range, on tablet and small-desktop viewports with multi-column grids, and disappears entirely on the one case people usually test — a phone showing a full-bleed image.

Over-fetch by viewport and slot width Horizontal bar chart of the waste column from the table above. A full-width slot on a 375 pixel viewport wastes nothing; a 50vw slot on the same viewport wastes 171 percent; 33vw slots on 768 and 1280 pixel viewports waste 429 percent; a 50vw slot on a 1440 pixel viewport wastes 95 percent. Bytes wasted when sizes is omitted, by viewport and slot 375 px vp · full-width slot 375 px vp · 50vw slot 768 px vp · 33vw slot 1280 px vp · 33vw slot 1440 px vp · 50vw slot 0% — the candidate is already correct +171% +429% +429% +95% 0% 100% 200% 300% 400% Waste = default-100vw candidate bytes ÷ accurate candidate bytes − 1, from the table above

Tradeoff: Omitting sizes is the single most common cause of 3–5× bandwidth over-spend on tablet and desktop layouts with multi-column grids. The browser cannot infer layout from CSS alone during preloading.

The asymmetry is a compression artefact rather than an arithmetic one. Bytes scale roughly with pixel count, so a 3× width error is closer to a 9× area error, and lossy encoders only claw part of that back: a 1440w WebP is 5.3× the weight of a 480w WebP even though it carries 9× the pixels. That is also why the error is bounded on mobile — at 375 px with a full-bleed slot the “wrong” and “right” candidates are the same file.


Step-by-Step Implementation

Step 1 — Audit your CSS layout widths

Before writing sizes, measure the actual rendered slot width at each breakpoint. In Chrome DevTools, select the <img> element and read the computed layout width from the “Box Model” panel at several viewport widths.

For a typical three-column grid layout:

Breakpoint Layout rule Rendered slot
< 640 px 1 column, full width 100vw
640–1023 px 2 columns, calc(50vw - 1rem) gutter calc(50vw - 1rem)
≥ 1024 px 3 columns, calc(33.333vw - 1.5rem) gutter calc(33vw - 1.5rem)
<img
  srcset="/img/card-480.webp   480w,
          /img/card-960.webp   960w,
          /img/card-1440.webp 1440w"
  sizes="(max-width: 639px)  100vw,
         (max-width: 1023px) calc(50vw - 1rem),
         calc(33vw - 1.5rem)"
  src="/img/card-960.webp"
  width="960" height="640"
  alt="Card image"
  loading="lazy"
  decoding="async">

Warning: sizes is evaluated by the preloader which does not have CSS available. You must replicate your CSS breakpoints manually in sizes. If CSS changes, update sizes in tandem.

Step 2 — Generate width-descriptor candidates with Sharp

The candidate widths in srcset should bracket the DPR-multiplied slot widths you identified in Step 1. For a maximum rendered slot of calc(50vw - 1rem) at a 1440 px viewport and DPR 2, the required pixel width is roughly (720 - 1) × 2 = 1438 px — so a 1440w candidate covers it exactly.

// generate-responsive.mjs — run via: node generate-responsive.mjs
import sharp from 'sharp';        // npm i sharp
import { resolve } from 'path';

const SOURCE   = resolve('src/img/hero.jpg');
const OUT_DIR  = resolve('public/img');

// Widths chosen to cover: 375×1 (375), 375×2 (750), 640×1 (640),
// 768×2 (1536), 1024×1 (1024), 1440×1 (1440), 1440×2 (2880 — capped at src width)
const WIDTHS   = [480, 768, 960, 1440, 1920];
const QUALITY  = 80;   // WebP quality — 80 is the industry sweet-spot for photographic content

await Promise.all(
  WIDTHS.map(w =>
    sharp(SOURCE)
      .resize(w)                       // resize to width, preserve aspect ratio
      .webp({ quality: QUALITY,        // lossy WebP
               effort: 6 })            // effort 6 = good compression, reasonable encode time
      .toFile(`${OUT_DIR}/hero-${w}.webp`)
  )
);

console.log('Generated:', WIDTHS.map(w => `hero-${w}.webp`).join(', '));

For AVIF — derived from the AV1 codec — add a parallel pass with .avif({ quality: 60, effort: 7 }). AVIF’s encode time is 3–8× longer than WebP at equivalent quality so keep it in an async CI step rather than a hot path.

Step 3 — Wrap in <picture> for format negotiation

Resolution switching via srcset/sizes and format negotiation via <picture> compose cleanly. The browser applies the media and type checks on <source> elements first, then evaluates srcset/sizes within the winning source.

<picture>
  <!-- AVIF: best compression, narrower support; browser picks this if it can decode -->
  <source
    type="image/avif"
    srcset="/img/hero-480.avif   480w,
            /img/hero-960.avif   960w,
            /img/hero-1440.avif 1440w,
            /img/hero-1920.avif 1920w"
    sizes="(max-width: 639px)  100vw,
           (max-width: 1023px) calc(50vw - 1rem),
           calc(33vw - 1.5rem)">

  <!-- WebP: excellent compression, universal modern support -->
  <source
    type="image/webp"
    srcset="/img/hero-480.webp   480w,
            /img/hero-960.webp   960w,
            /img/hero-1440.webp 1440w,
            /img/hero-1920.webp 1920w"
    sizes="(max-width: 639px)  100vw,
           (max-width: 1023px) calc(50vw - 1rem),
           calc(33vw - 1.5rem)">

  <!-- JPEG fallback for Safari 13 and IE 11 -->
  <img
    src="/img/hero-960.jpg"
    width="1440" height="960"
    alt="Hero: adaptive layout with responsive image delivery"
    loading="eager"
    fetchpriority="high">
</picture>

For more complex crop-based breakpoints — where the image composition itself must change rather than just scale — see Art Direction with the HTML Picture Element.

Step 4 — Reserve layout dimensions

Always set width and height on the <img> element to match the intrinsic aspect ratio of the full-size source. The browser uses these to reserve layout space before the image arrives, eliminating CLS without requiring aspect-ratio CSS.

<!-- source is 1440 × 960 px (3:2 ratio) -->
<img src="..." width="1440" height="960" ...>
<!-- browser computes aspect-ratio: 1440/960 = 1.5 and reserves the slot -->

Warning: Setting width/height to the rendered size rather than the intrinsic source size breaks this mechanism. Use intrinsic dimensions and let CSS control the rendered size via max-width: 100%.

Step 5 — Set loading and fetchpriority

  • loading="eager" + fetchpriority="high" for the Largest Contentful Paint candidate (typically the first above-the-fold image).
  • loading="lazy" + decoding="async" for all below-the-fold images.

Warning: Using fetchpriority="high" on more than one or two images simultaneously signals high priority for all of them, which can starve CSS and font downloads and actually delay LCP. Reserve it for a single hero image per page.

Step 6 — Cap the descriptor ladder

A candidate list is a sampling of a continuous function, and the sampling error is what you actually ship. If adjacent candidates are 960w and 1920w and the required width is 1000 px, the browser must round up to 1920w and the reader pays for 920 px of unused resolution. The rule that keeps that bounded: no gap between adjacent candidates should exceed roughly 30% of the smaller one, across the range your sizes value can actually produce.

// Derive a candidate ladder from the min/max slot widths your sizes value can yield.
// minSlot/maxSlot are CSS px; dprs are the densities you actually serve.
function ladder(minSlot, maxSlot, dprs = [1, 2, 3], step = 1.3, sourceWidth = Infinity) {
  const lo = Math.round(minSlot * Math.min(...dprs));
  const hi = Math.min(Math.round(maxSlot * Math.max(...dprs)), sourceWidth); // never upscale past the master
  const out = [];
  for (let w = lo; w < hi; w = Math.round(w * step)) out.push(w);  // 1.3 → ≤30% gap
  out.push(hi);                                                    // always include the ceiling exactly
  return out;
}

// A 33vw grid slot: 120 px at 360 vw, 640 px at 1920 vw
console.log(ladder(120, 640, [1, 2, 3], 1.3, 2560));
// → [120, 156, 203, 264, 343, 446, 580, 754, 980, 1274, 1656, 1920]

Tradeoff: That ladder is deliberately too long. Twelve variants per image is fine for a CDN that resizes on demand and terrible for a static build — every extra width is another file, another cache key, and another entry in the HTML. For build-time pipelines, thin it to four to six widths and accept a slightly larger rounding error; for on-the-fly resizing at the edge, keep the fine ladder and let the cache warm.


sizes="auto" and Lazily Loaded Images

Every sizes value discussed so far is a prediction, written by hand, about a layout the preloader cannot see. For lazily loaded images that prediction is unnecessary: by the time a loading="lazy" image is fetched, layout has already run and the browser knows the element’s concrete width. sizes="auto" tells it to use that number instead of the author’s guess.

<!--
  sizes="auto" is only honoured when loading="lazy" is also present.
  The written list after it is the fallback used by engines that do not
  implement auto sizes — they parse "auto" as an invalid source size,
  discard it, and continue with the remaining entries.
-->
<img
  srcset="/img/card-320.avif  320w,
          /img/card-480.avif  480w,
          /img/card-720.avif  720w,
          /img/card-1080.avif 1080w"
  sizes="auto, (max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
  src="/img/card-480.avif"
  width="1080" height="720"
  alt="Product card illustration"
  loading="lazy"
  decoding="async">
<!-- loading="lazy" above is required: auto sizes is ignored on eagerly fetched images. -->

Three constraints follow from the mechanism. First, sizes="auto" is meaningless on an eager image — there is no layout yet, and the browser falls back to 100vw, which is precisely the failure mode you were avoiding. Second, an element whose computed width is 0 at fetch time (a collapsed accordion panel, a display: none tab) yields a zero slot, and the browser then requests the smallest candidate; expand the panel and you get a blurry image until something mutates the source set. Third, auto sizes removes the drift problem entirely: refactor the grid and the images follow, because nothing about the layout is duplicated in the markup any more.

Tradeoff: sizes="auto" shifts the resource decision from the preloader to the lazy-load scheduler, which means it can never be used for the LCP image. Treat it as the default for below-the-fold content and keep hand-calculated sizes for anything above the fold — the split most framework image components now make for you.


Client Hints: Moving Selection to the Server

srcset puts the entire candidate list in the HTML. Client hints invert that: the browser sends its viewport, density, and requested resource width as request headers, and the server or CDN returns a single correctly sized image at one URL. The two approaches are complementary — hints eliminate the markup bloat, srcset eliminates the round-trip guesswork.

<!-- Opt in to width hints for this document and any subresource origins you delegate to. -->
<meta http-equiv="Accept-CH" content="Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-Width">
# Response headers from the HTML origin.
# Accept-CH advertises which hints the browser may send on subsequent requests.
add_header Accept-CH "Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-Width";

# Critical-CH re-runs the navigation request with the hints attached, so even the
# first HTML response can be personalised — costs one extra round trip on cold visits.
add_header Critical-CH "Sec-CH-DPR";

# Delegate the hints to a third-party image origin; without this, cross-origin
# image requests are sent without any Sec-CH-* headers.
add_header Permissions-Policy 'ch-dpr=(self "https://img.example.com"), ch-width=(self "https://img.example.com")';

location ~* \.(avif|webp|jpg)$ {
  # Any header used for content negotiation MUST appear in Vary or caches will
  # serve a 1x image to a 3x device.
  add_header Vary "Accept, Sec-CH-DPR, Sec-CH-Width";
}

Sec-CH-Width is the one that interacts with sizes directly: the browser computes the same source size your sizes attribute declares, multiplies by DPR, rounds up to the next multiple of a browser-chosen quantum (Chrome uses 16 px buckets today), and sends that as the desired resource width. An origin that honours it can return a pixel-exact image and skip the ladder in Step 6 entirely.

Warning: Every hint you add to Vary multiplies the cache key space. Vary: Accept, Sec-CH-DPR, Sec-CH-Width with 16 px width quantisation can produce hundreds of variants for a single logical image, which destroys edge hit ratios unless the CDN normalises the header into a small number of buckets before it reaches the cache — the same normalisation pattern used for Accept-header format negotiation at the edge.


Parameter Reference

Attribute / value Where to set it Effect
srcset="… 480w" <img> or <source> Provides a width descriptor; browser divides by DPR-adjusted slot to select
sizes="(max-width: 640px) 100vw, 50vw" <img> or <source> Media conditions evaluated left-to-right; first match sets slot width
src="fallback.jpg" <img> Fallback for browsers that don’t support srcset; also used as the preload key
width / height <img> Intrinsic dimensions — must match source file ratio, not rendered size
loading="lazy" <img> Defers fetch until image is near viewport; ignored for eager/above-the-fold
loading="eager" <img> Immediate fetch; default behaviour; pair with fetchpriority="high" for LCP
fetchpriority="high" <img> Elevates preloader priority; use on at most one LCP image per page
fetchpriority="low" <img> Reduces priority; useful for images below the fold in a carousel
decoding="async" <img> Decompresses off main thread; reduces INP contention during heavy layout
type="image/avif" <source> Content-type hint; browser skips source if format is not supported
media="(min-width: 640px)" <source> Selects the whole source set, not a candidate within it; evaluated before sizes
sizes="auto, …" <img loading="lazy"> Uses the element’s laid-out width; the list after auto is the fallback for engines without support
srcset="… 2x" <img> / <source> Density descriptor; cannot appear in the same list as a w descriptor
image-set() CSS background-image Density and type() negotiation for CSS backgrounds; has no sizes equivalent
Accept-CH: Sec-CH-Width HTTP response Opts the document in to width hints on subsequent image requests
Vary: Sec-CH-DPR image response Mandatory whenever a hint changes the bytes returned, or caches will cross-serve densities

Tradeoffs & Edge Cases

Tradeoff: sizes must duplicate your CSS breakpoints. The preloader has no access to stylesheets. When you refactor responsive CSS grid logic, sizes will drift silently, causing the wrong candidate to be chosen. The solution is to generate sizes programmatically from your design token breakpoints at build time. See How to calculate optimal sizes attribute values for a viewport-mapping script.

Tradeoff: Safari rounds fractional DPR. On Safari running on Retina MacBooks, window.devicePixelRatio is often 2.0 exactly. On iPhone Pro models it is 3.0. But certain Android Chrome builds report fractional values like 2.625. Safari internally rounds these up to the nearest integer when selecting a candidate. Providing discrete 1x, 2x, and 3x width buckets prevents blurry rendering on edge-case devices.

Tradeoff: The browser may override your selection for cached candidates. If a larger image is already in the HTTP cache, Chrome will prefer it over a smaller fresh candidate to avoid a visible quality downgrade. This is intentional behaviour — it means your actual network requests will differ from a cold-load analysis in WebPageTest.

Tradeoff: calc() in sizes is parsed but not always preloaded optimally. The WHATWG spec permits calc() expressions in sizes, and all modern browsers parse them correctly. However, Chrome’s preloader sometimes substitutes a simplified approximation for complex calc() expressions. Keep calc() to simple addition/subtraction of viewport units and fixed pixel values.

Tradeoff: <picture> with both srcset on <source> and srcset on <img>. If <source> elements match, the browser ignores the <img srcset> entirely. Do not duplicate the same srcset on <img> inside a <picture> block — only use <img src> as the JPEG/PNG fallback.

Warning: candidates must share an aspect ratio. Nothing in the selection algorithm verifies that hero-480.webp and hero-1440.webp have the same proportions. If one variant is generated with a different crop — easy to do when a CMS re-crops for thumbnails — the reserved slot from width/height is wrong for that candidate and you get a layout shift only on the devices that select it. Assert the ratio in the build: reject any variant whose width / height differs from the master by more than 1%.

Tradeoff: a sizes value larger than the viewport is legal and occasionally correct. A transform: scale() zoom, a horizontally scrolling carousel, or a position: fixed overlay can render an image wider than 100vw. sizes="150vw" is valid and the preloader will honour it. It is also the single easiest way to accidentally quadruple your image budget, so annotate any such value in the source with the reason it exceeds the viewport.

Warning: descriptors are not validated against the actual files. If hero-960.webp is really 900 px wide, the browser still treats it as a 960 px resource and computes densities from the lie. The rendered result is a slightly soft image that no audit flags, because Lighthouse compares the declared size to the slot. Generate the descriptors from the encoder’s output metadata, never from the filename template.

Tradeoff: CSS backgrounds get none of this. background-image has no sizes and no width descriptors; image-set() offers density and type() negotiation only. A decorative hero that must be resolution-switched by layout width belongs in an <img> with object-fit: cover, not in CSS — which is also the only way it can become an LCP candidate the preloader will fetch early.


Format Support Reference

WebP achieved universal support first and requires no fallback in modern browsers. AVIF requires a <source type="image/avif"> fallback chain for Safari 14–15. Configure correct Content-Type headers for both formats — a missing or wrong MIME type causes the browser to reject the source silently. See MIME type configuration for modern media servers for Nginx, Apache, and Caddy config examples.

Format Chrome Firefox Safari 14 Safari 16+ Edge 18+ source needed
AVIF 85+ 93+ No Yes 85+ Yes — for Safari 14–15
WebP 32+ 65+ Yes (14+) Yes 18+ No
JPEG/PNG All All All All All Baseline <img src>

Set appropriate Cache-Control headers for image assets alongside format negotiation. Images with content-hashed filenames can use Cache-Control: public, max-age=31536000, immutable. Without immutable caching, format-negotiated images may be re-requested on every navigation despite being identical.


Debugging & Validation

Check which candidate the browser selected

In Chrome DevTools → Network panel, filter by Img. Click the image request and inspect the Request URL — it shows the exact candidate filename the preloader chose. The “Initiator” column shows Parser if the preloader triggered it (correct) vs Script if JS triggered it (too late for LCP optimisation).

# Verify the image response includes the correct Content-Type
curl -sI https://example.com/img/hero-960.webp \
  | grep -i 'content-type'
# Expected: content-type: image/webp

# Check no Vary: Accept header is missing (required for CDN to serve correct format variant)
curl -sI https://example.com/img/hero-960.webp \
  | grep -i 'vary'
# Expected: vary: Accept  (or at minimum: vary: Accept-Encoding)

Audit with Lighthouse

Run Lighthouse → Performance → “Properly size images” audit. A passing score means the served image width is within 10% of the rendered slot width. A failing score includes the exact bytes wasted and which images are affected.

# CLI audit — requires Node + Lighthouse
npx lighthouse https://example.com --only-audits=uses-responsive-images \
  --output json | jq '.audits["uses-responsive-images"].details.items'

Validate srcset parsing in the browser console

// Paste in DevTools console to inspect a specific image's candidate selection
const img = document.querySelector('img.hero');
console.log('currentSrc:', img.currentSrc);
// currentSrc shows the actual URL the browser chose from the srcset list

console.log('naturalWidth:', img.naturalWidth, 'naturalHeight:', img.naturalHeight);
// naturalWidth should be >= rendered slot × devicePixelRatio
// if naturalWidth << slot×DPR, a larger srcset candidate is needed

Score every image on the page at once

currentSrc tells you what was chosen; the Resource Timing entry tells you what it cost. Joining them gives a single number per image — decoded pixels delivered per pixel painted — that is easy to assert on in CI.

// Paste into DevTools, or run inside page.evaluate() in Playwright/Puppeteer.
// Flags any image whose delivered width exceeds the slot it was painted into.
const dpr = window.devicePixelRatio || 1;

const report = [...document.images]
  .filter(img => img.currentSrc && img.complete)
  .map(img => {
    const slot     = Math.round(img.getBoundingClientRect().width);
    const needed   = Math.round(slot * dpr);           // physical pixels the layout actually needs
    const timing   = performance.getEntriesByName(img.currentSrc)[0];
    return {
      src:      img.currentSrc.split('/').pop(),
      slot,
      needed,
      natural:  img.naturalWidth,                       // real width of the chosen file
      overshoot: +(img.naturalWidth / needed).toFixed(2),
      kb:       timing ? Math.round(timing.transferSize / 1024) : null  // 0 when served from cache
    };
  })
  .filter(r => r.needed > 0)
  .sort((a, b) => b.overshoot - a.overshoot);

console.table(report);
// overshoot > 1.3 → the sizes value or the candidate ladder is too coarse
// overshoot < 0.9 → the image is being upscaled and will look soft

Run it in a Playwright test at three viewport widths and fail the build if any above-the-fold image reports an overshoot above 1.3. That single assertion catches the two regressions that matter — a CSS refactor that shrank a column without updating sizes, and a new candidate ladder with a gap that is too wide — without needing a full Lighthouse run in the pull-request pipeline. For byte budgets rather than per-image ratios, wire it alongside Lighthouse CI image-weight budgets.

WebPageTest filmstrip

In WebPageTest, the filmstrip view reveals whether LCP images are preloaded or deferred. An image that appears in the filmstrip after the third frame (>600 ms) on a 4G profile is almost always caused by missing fetchpriority="high" or a sizes value that confused the preloader. Use the “Request Details” waterfall to confirm the image request starts within 200 ms of navigation start for above-the-fold content.


FAQ

Can I mix w and x descriptors in one srcset?

No. A source set that mixes width and density descriptors is a parse error. Browsers discard the offending candidates, and if none survive they fall back to src — with no console warning. Choose one family per source set: x for fixed slots, w everywhere else.

Does sizes accept percentages?

No. A source size value must be a CSS <length>: 640px, 50vw, calc(50vw - 24px) or min(100vw, 960px). A bare 50% is invalid because the preloader has no containing block to resolve it against. This is the single most common reason a hand-written sizes string silently reverts to 100vw.

Why did the browser fetch a larger candidate than my sizes value implies?

Almost always cache reuse. If a larger candidate for the same element is already in the HTTP cache, Chrome and Firefox prefer it rather than downloading a smaller file and visibly downgrading quality. Always verify candidate selection on a cold cache, in an incognito window or with the Network panel’s “Disable cache” checked.

Should the src fallback be the smallest or the largest candidate?

Neither — pick a mid-range candidate. src is only used by engines that ignore srcset entirely, and it is also the URL the browser uses as the image’s key before selection completes. A tiny fallback looks broken on the rare client that uses it; the largest one becomes an expensive mistake if the source set ever fails to parse.

How many candidates should a srcset contain?

Enough that adjacent widths differ by no more than about 30%, across the whole range your sizes value can produce, and no more. Four to six widths covers most layouts. Beyond that you are paying build time and fragmenting CDN caches for rounding error that the encoder mostly absorbs anyway.

Does srcset work on <video> posters or <source> inside <video>?

No. The poster attribute takes a single URL and video <source> elements negotiate on type and media only. Resolution switching for video is handled by adaptive bitrate manifests instead — see responsive video delivery in Next.js and React.