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

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">

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%

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.


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.


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

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.


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

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.