Density Descriptors vs Width Descriptors in srcset
A srcset attribute may carry density descriptors (2x) or width descriptors (960w), and the HTML parser rejects any list that contains both. Choosing the wrong family is not a syntax problem — it is a silent delivery problem: an x set on a fluid image ships a 1440 px file into a 328 px phone slot, and a w set with no sizes attribute collapses to 100vw on every viewport. This page gives the rule that decides the family, the arithmetic that makes both families comparable, and a lossless conversion in either direction. It is the descriptor-level companion to Mastering srcset and sizes for Responsive Layouts, which covers the surrounding attributes; here we look only at what is written after each URL.
Prerequisite checklist
The one number both families produce
The selection algorithm never compares a w value to an x value. It converts every candidate into a single normalized density and then picks the smallest candidate whose normalized density is greater than or equal to the device pixel ratio, falling back to the largest candidate if none reaches it. The conversion is trivial:
- For an
xdescriptor, the normalized density is the descriptor.[email protected] 2xhas density 2. - For a
wdescriptor, the normalized density isdescriptor ÷ source size in CSS pixels.card-960.webp 960win a 427 px slot has density 2.25. - A candidate with no descriptor at all is treated as
1x.
That single divisor is the entire difference between the families. A density descriptor is a pre-divided width: the author has already performed the division at build time because the denominator — the slot — is a constant they know. A width descriptor defers the division to the browser, which is why it needs sizes to supply the denominator. Everything else about the two syntaxes is identical, including the tie-breaking, the fallback to the largest candidate, and the fact that no console message is ever emitted.
The consequence is easiest to see on an axis. A density set sits at fixed points on the normalized-density line no matter what the layout does. A width set slides along that line as the slot grows or shrinks, which is exactly the behaviour a fluid image needs and exactly the behaviour a fixed icon does not want.
Read the bottom two rows together. At a 427 px slot the 2160w file sits at 5.06 and is unreachable — no device asks for five times its layout width. Widen the slot to 800 px and the same file drops to 2.70, comfortably inside the range a DPR 3 phone will request. Nothing about the file changed; only the denominator did. That mobility is the argument for w, and the reason a w set cannot be reasoned about without knowing what sizes can produce.
When density descriptors are the correct answer
Use x when the rendered width is a constant that you control, across every viewport, container, and orientation. In practice that means header logos, avatars, favicons rendered in-page, category icons, map pins, star ratings, payment-provider badges, and any image sized by a design token rather than by a grid fraction. Three properties make them a good fit:
- The denominator is genuinely fixed, so pre-dividing at build time loses no information.
- The list is short and bounded — one URL per density, typically two or three. A
wladder for the same asset would be the same two or three files with more verbose markup and a mandatorysizesstring that only ever evaluates to one value. - There is nothing to drift. A
wset duplicates layout knowledge into the HTML, so a CSS refactor can silently invalidate it. Anxset on a fixed slot has no layout knowledge to invalidate; the only way to break it is to change the slot width itself, which changes the CSS and thewidthattribute at the same time.
Tradeoff: the third property inverts the moment the slot stops being fixed. A component that starts as a 40 px avatar and later grows to clamp(40px, 8vw, 96px) will keep serving the 2× file to a DPR 3 phone showing it at 96 px, producing a visibly soft image with no build error and no audit failure. If a component’s width is even plausibly going to become fluid, pay the sizes tax up front.
The parse rules that decide whether your list survives
Before selection runs, the source set is tokenized, and several combinations delete candidates outright. Because the failure path is a silent fallback to src, it is worth knowing which errors kill a single candidate and which kill the whole list.
The amber path is worth internalising because it is the only one that degrades gradually. Two candidates that resolve to the same descriptor — easy to produce when a CDN template rounds widths — cost you a rung on the ladder and nothing else. The red path is total: a single stray 2x inside an otherwise correct w list can leave the element with nothing but its src, which on most pages is the mid-range fallback and therefore looks almost right, hiding the bug for months.
One asymmetry follows from all of this and is worth stating plainly: the density family is available everywhere the image can appear, and the width family is not. CSS image-set() accepts density descriptors and an optional type() hint, but it has no width descriptors and no sizes equivalent, because a background-image has no element-level slot the preloader could divide by. So an asset that must be resolution-switched by layout width has to live in an <img>; an asset that only needs to be resolution-switched by device density can live in either. That constraint frequently decides the markup before the descriptor question is even asked, and it is one more reason to keep decorative-but-important imagery out of stylesheets.
The complete pattern: one family per source set
<!--
PATTERN A — FIXED SLOT, DENSITY DESCRIPTORS.
The header logo is 120 CSS px wide at every breakpoint. Because the
denominator never moves, pre-divide at build time: one file per density.
src= the 1x file. Used verbatim by engines without srcset support,
and used as the element's key before selection resolves.
srcset= one candidate per density. 120 / 240 / 360 physical px give
normalized densities of exactly 1.0, 2.0 and 3.0.
width= intrinsic width of the 1x file — this is what reserves the slot.
height= intrinsic height of the 1x file. With width it establishes the
aspect ratio, so no layout shift occurs before decode.
decoding= async keeps decompression off the main thread.
NO sizes= on a density set the attribute is parsed and then discarded.
-->
<img src="/img/logo-120.png"
srcset="/img/logo-120.png 1x,
/img/logo-240.png 2x,
/img/logo-360.png 3x"
width="120"
height="40"
alt="Acme"
decoding="async">
<!--
PATTERN B — FLUID SLOT, WIDTH DESCRIPTORS.
The card image is the viewport minus gutters on phones, half of it on
tablets and a third on desktop, so the denominator is a function of the
viewport and must be handed to the browser explicitly.
src= a MID-ladder fallback. The smallest looks broken on the rare
engine that uses it; the largest is an expensive mistake if the
source set ever fails to parse.
srcset= descriptor = the file's REAL intrinsic width in pixels, not the
width you wish it were. The browser trusts this number and never
measures the decoded image.
sizes= media conditions evaluated left to right, first match wins; the
unconditional value must come last. Each entry mirrors the CSS
rule that sets the slot at that breakpoint.
width= intrinsic size of the LARGEST candidate. Every candidate must
height= share this 3:2 ratio, or the reserved box is wrong for whichever
variant gets selected on a given device.
loading= lazy, because the card sits below the fold.
-->
<img src="/img/card-720.avif"
srcset="/img/card-320.avif 320w,
/img/card-480.avif 480w,
/img/card-720.avif 720w,
/img/card-1080.avif 1080w,
/img/card-1440.avif 1440w"
sizes="(max-width: 639px) calc(100vw - 32px),
(max-width: 1023px) calc(50vw - 24px),
calc(33vw - 24px)"
width="1440"
height="960"
alt="Weekly delivery report card"
loading="lazy"
decoding="async">
Warning: sizes on Pattern A is not a harmless no-op in review terms — it is an active source of confusion, because engineers read it and assume it is influencing selection. Delete it. Conversely, omitting sizes from Pattern B is not a small error: the slot silently becomes 100vw, which on a 1440 px desktop with a 33vw grid over-fetches by roughly 3×.
Converting between the families
The two syntaxes are interchangeable whenever the slot is fixed, and the conversion is a multiplication. Given a fixed slot of S CSS pixels and densities d₁…dₙ, the equivalent width descriptors are S × dᵢ and the sizes value is S in px:
<!-- Density form: 120 px slot -->
<img srcset="/img/logo-120.png 1x, /img/logo-240.png 2x, /img/logo-360.png 3x"
src="/img/logo-120.png" width="120" height="40" alt="Acme">
<!-- Width form: identical selection on every device -->
<img srcset="/img/logo-120.png 120w, /img/logo-240.png 240w, /img/logo-360.png 360w"
sizes="120px"
src="/img/logo-120.png" width="120" height="40" alt="Acme">
Both forms compute the same normalized densities (120 ÷ 120 = 1, 240 ÷ 120 = 2, 360 ÷ 120 = 3), so every device downloads the same file. The width form buys you one thing: if the slot later becomes clamp(120px, 20vw, 240px), you edit sizes and the existing files keep working. The density form buys you a different thing: there is no second place for the layout to be described, so there is nothing to forget.
Going the other way — collapsing a w set into an x set — is only lossless if sizes resolves to a single value on every device. If sizes contains any viewport unit, the conversion discards information and you will over- or under-serve at some viewport. The grid below shows what that costs on a card whose slot really is fluid.
The last row is the honest case for density descriptors: on a slot that truly does not move, they are exact on every profile, and no sizes string exists that can drift out of sync with the CSS. The third row is the failure that motivates this page — the same three files, the same three devices, but a fluid denominator that the markup never mentions, producing 46 % waste on the profile that can least afford it.
Verification
1. Assert descriptor honesty against the real files
The browser never measures a candidate; it believes the descriptor. A resizer that clamps at the source width — sharp().resize(2160) on a 1800 px master, for instance — emits a file narrower than its filename claims, and every density derived from it is inflated. Check the actual intrinsic width of everything you ship:
# ImageMagick: print "filename WIDTHxHEIGHT" for every generated variant
identify -format '%f %wx%h\n' public/img/card-*.avif
# Then confirm each descriptor matches. Fails loudly on any mismatch.
for f in public/img/card-*.avif; do
declared="${f##*-}"; declared="${declared%%.*}" # card-1080.avif -> 1080
actual=$(identify -format '%w' "$f") # true intrinsic width
[ "$declared" = "$actual" ] || echo "MISMATCH $f declares ${declared}w but is ${actual}px"
done
2. Force a device scale factor and read currentSrc
Chrome’s --force-device-scale-factor changes the value the selection algorithm reads for DPR, which is the only input you cannot set from the page. Run three headless passes and compare:
# --force-device-scale-factor sets window.devicePixelRatio for the whole browser,
# so the selection algorithm sees a genuine 3x device rather than an emulated one.
for dpr in 1 2 3; do
google-chrome --headless --disable-gpu \
--force-device-scale-factor=$dpr \
--window-size=1440,900 \
--virtual-time-budget=4000 \
--dump-dom https://example.com/ \
| grep -o 'logo-[0-9]*\.png' | sort -u | sed "s/^/DPR $dpr: /"
done
# Expected for the Pattern A logo: DPR 1 -> logo-120, DPR 2 -> logo-240, DPR 3 -> logo-360.
3. Confirm no page mixes the two families
A single mixed list is a total failure, so it belongs in CI rather than in a manual pass. Grep the built HTML for any srcset value that contains both a w and an x descriptor:
# Extract every srcset value from the build output, then flag lists whose
# descriptors are not all from one family. Exit non-zero so CI stops.
grep -roh 'srcset="[^"]*"' dist/ \
| sort -u \
| awk '/[0-9]w[ ,"]/ && /[0-9](\.[0-9]+)?x[ ,"]/ { print "MIXED FAMILIES: " $0; bad=1 }
END { exit bad }'
To watch the same decision play out interactively rather than in a script, the DevTools procedure in auditing srcset candidate selection with DevTools shows which candidate won and why, viewport by viewport.
Common mistakes and fixes
1. Mixing w and x in one list
<!-- WRONG: the 2x entry poisons a width-descriptor list. -->
<img srcset="/img/card-480.avif 480w, /img/card-960.avif 2x" sizes="50vw" src="/img/card-720.avif" alt="Card">
<!-- CORRECT: pick one family. 960 px is the file's real width, so it is 960w. -->
<img srcset="/img/card-480.avif 480w, /img/card-960.avif 960w" sizes="50vw" src="/img/card-720.avif" alt="Card">
2. A bare URL alongside width descriptors
A candidate with no descriptor defaults to 1x, which puts a density candidate into a width list and triggers the same parse error as an explicit 2x. This shows up when a template concatenates a legacy src into the front of a generated srcset.
<!-- WRONG: the first entry is implicitly 1x. -->
<img srcset="/img/card.avif, /img/card-960.avif 960w" sizes="50vw" alt="Card">
<!-- CORRECT: give every candidate an explicit width descriptor. -->
<img srcset="/img/card-720.avif 720w, /img/card-960.avif 960w" sizes="50vw" alt="Card">
3. Writing sizes for a density source set
The attribute is parsed, validated, and then ignored, which makes it worse than useless: reviewers assume the image is viewport-aware when it is not.
<!-- WRONG: sizes has no effect on an x-descriptor list. -->
<img srcset="/img/hero.jpg 1x, /img/[email protected] 2x" sizes="(max-width: 600px) 100vw, 50vw" alt="Hero">
<!-- CORRECT: a fluid hero needs width descriptors. -->
<img srcset="/img/hero-640.jpg 640w, /img/hero-1280.jpg 1280w, /img/hero-1920.jpg 1920w"
sizes="(max-width: 600px) 100vw, 50vw" alt="Hero">
4. Density descriptors on an image with width: 100%
The most common version of this ships a component library’s 2x avatar into a profile-header layout that renders it at 25 % of the viewport. The fix is not a bigger 2x file — it is the w family, because no constant can satisfy every viewport.
<!-- WRONG: fluid CSS width, fixed density ladder. -->
<img class="avatar-fluid" srcset="/av-48.png 1x, /av-96.png 2x" src="/av-48.png" alt="">
<!-- CORRECT: express the ladder in real pixels and hand the browser the slot. -->
<img class="avatar-fluid"
srcset="/av-48.png 48w, /av-96.png 96w, /av-192.png 192w, /av-320.png 320w"
sizes="(max-width: 600px) 25vw, 96px" src="/av-96.png" alt="">
5. Shipping a 3x variant for a large surface
A 3x file carries nine times the pixels of the 1x and, after encoding, roughly four to six times the bytes — an increase most viewers cannot resolve on a photographic surface larger than a thumbnail. Cap the density ladder at 2x for anything above roughly 200 CSS px and let the width family handle high-DPR phones, where sizes already narrows the slot. The relative cost of that extra rung differs sharply by format; the AVIF vs WebP compression benchmarks give the byte ratios to plug in.
Tradeoff: the same argument does not apply to flat-colour UI assets. A 3x icon is a few hundred extra bytes and prevents visible resampling on the exact devices most likely to be held close to the eye — keep the third rung for logos, glyphs, and line art, and drop it for photographs.
Related
- Mastering srcset and sizes for Responsive Layouts — the full attribute reference, selection algorithm, and
<picture>composition rules this page narrows down - How to Calculate Optimal sizes Attribute Values — deriving the denominator that width descriptors depend on, from real CSS geometry
- Auditing srcset Candidate Selection with DevTools — proving which candidate a browser actually chose at each viewport and device pixel ratio
- Vite imagetools Responsive srcset Generation — emitting either descriptor family from a build pipeline instead of hand-writing candidate lists