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
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.
Related
- How to calculate optimal sizes attribute values — viewport-mapping formulas and a build-time script for generating accurate
sizesstrings from CSS breakpoints - Art Direction with the HTML Picture Element — crop and composition switching across breakpoints using
<picture>mediadescriptors - Responsive Image & Video Delivery — parent overview: the full scope of adaptive delivery patterns
- AVIF vs WebP compression benchmarks — file-size and quality tradeoffs that inform which formats to include in your
<source>chain - Using fetchpriority to optimise critical media — how
fetchpriority="high"interacts with the preloader on LCP images - Responsive video delivery in Next.js and React — framework-level srcset generation via the Next.js Image component and custom loaders