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 x descriptor, the normalized density is the descriptor. [email protected] 2x has density 2.
  • For a w descriptor, the normalized density is descriptor ÷ source size in CSS pixels. card-960.webp 960w in 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.

Normalized density line for x and w descriptor sets A horizontal axis of normalized density from zero to six. Density descriptors 1x, 2x and 3x sit at exactly 1, 2 and 3 regardless of layout. The same four width descriptors 480w, 960w, 1440w and 2160w land at 1.12, 2.25, 3.37 and 5.06 in a 427 pixel slot, but slide down to 0.60, 1.20, 1.80 and 2.70 in an 800 pixel slot. Where each candidate lands on the normalized-density axis DPR 1 DPR 2 DPR 3 x descriptors any slot 1x 2x 3x w descriptors 427 px slot 480w 960w 1440w 2160w same w list 800 px slot 480w 960w 1440w 2160w 0 1 2 3 4 5 6 normalized density = w descriptor ÷ slot in CSS px, or the x descriptor itself

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:

  1. The denominator is genuinely fixed, so pre-dividing at build time loses no information.
  2. The list is short and bounded — one URL per density, typically two or three. A w ladder for the same asset would be the same two or three files with more verbose markup and a mandatory sizes string that only ever evaluates to one value.
  3. There is nothing to drift. A w set duplicates layout knowledge into the HTML, so a CSS refactor can silently invalidate it. An x set 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 the width attribute 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.

srcset parse outcomes by descriptor family A flow starting at the srcset attribute. If every candidate carries a w descriptor the list is valid and sizes supplies the denominator. If every candidate carries an x descriptor or no descriptor the list is valid and sizes is ignored. If w and x descriptors appear together, or a bare URL sits alongside w descriptors, the offending candidates are dropped and the element falls back to the src attribute. A duplicate descriptor value drops one candidate but leaves the rest of the list usable. tokenize srcset split on commas which descriptor family appears? bare URL counts as 1x all w list valid — sizes supplies the denominator; absent sizes means the slot is assumed to be 100vw all x list valid — sizes is parsed and then discarded; selection uses the device pixel ratio alone w and x together parse error — offending candidates removed; if nothing survives the element renders src, silently duplicate descriptor value, 0w, or a negative density one candidate dropped, list still usable

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.

Delivered pixels per needed pixel, by strategy and device A four row by three column grid. Width descriptors with an accurate sizes value deliver between 1.00 and 1.10 times the needed pixels on all three profiles. Width descriptors with sizes omitted deliver 1.10, 0.94 and 3.19. Density descriptors on the same fluid slot deliver 1.46, 1.33 and 1.06. Density descriptors on a genuinely fixed 120 pixel slot deliver exactly 1.00 on all three profiles. Delivered px ÷ needed px (1.00 is exact; above 1.3 is waste, below 1.0 is soft) 360 px vp · DPR 3 768 px vp · DPR 2 1440 px vp · DPR 1 w descriptors + accurate sizes fluid card slot 1.10 1.00 1.06 w descriptors, sizes omitted slot assumed 100vw 1.10 0.94 3.19 x descriptors on the same fluid card slot 1.46 1.33 1.06 x descriptors on a fixed 120 px logo slot 1.00 1.00 1.00 Ladder 320/480/720/1080/1440; card slot 100vw−32px, 50vw−24px, 33vw−24px; logo ladder 120/240/360.

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.