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