Performance

Responsive Images: srcset and sizes Without Guessing

srcset is the most widely deployed and least understood piece of the responsive web. Most implementations get the descriptor right and the sizes attribute wrong, which produces a browser that downloads a larger file than intended — and no error to tell you.

The misunderstanding has one root cause, and it is worth getting straight before writing any markup.

sizes is the layout width, not the image width

The HTML specification is explicit: the size value “gives the intended layout width of the image.” It describes the slot, not the file.

This is the opposite of what the attribute name suggests. People write sizes thinking they are describing their images; they are actually answering the question “how much horizontal space will this element occupy at this viewport?” — and the browser uses the answer to pick from the candidate list.

The spec also notes that size values “do not necessarily have to match up exactly with the actual image width as specified in the CSS” — they need to describe the layout well enough for a sensible choice, not perfectly.

The default that silently breaks everything

If you omit sizes, the documented default is 100vw.

That means every candidate is evaluated as if the image spans the full viewport width. On a 1440-pixel-wide screen, an <img srcset="a-480.jpg 480w, b-960.jpg 960w, c-1440.jpg 1440w"> with no sizes will be evaluated against 1440 CSS pixels of layout — and on a high-density display it will reach for an even larger candidate.

So the markup looks responsive and behaves the way a fixed-width image would. The w descriptors are doing nothing useful because the browser has been told the image fills the screen.

Two further rules from the spec:

  • Percentages are not allowed. Length units only, and the list must end with an unconditional value.
  • If sizes is present, all candidates must use w descriptors. Pairing sizes with x descriptors is meaningless, because the two describe different things — w is a real width, x is a pixel-density multiplier.

Which candidate the browser picks

The mechanism, per the spec: the user agent normalises each candidate’s width by the declared layout size to derive an effective density, and picks the best match for the current device pixel ratio.

There is one documented escape clause worth knowing: “User agents are free to choose any available source.” It is a permission, not a guarantee. The browser is allowed to pick a different candidate from the one you expected, and it may consider network conditions when it does.

That last point matters for expectations. MDN’s own responsive images guide describes selection in simplified terms that do not match the spec’s closest-density rule — so if the two descriptions differ, the specification is the one to trust. And the honest summary is this: srcset guarantees correct rendering, not bandwidth reduction. Whether the browser saves bytes depends on a decision it makes, guided by your hint.

Loading: the attribute that costs more than it saves

loading="lazy" is documented as deferring image loading until the browser calculates it is near the viewport. Two properties that matter:

  • The default is eager. Lazy loading is opt-in.
  • It requires JavaScript to be enabled. In a no-JS environment, lazy images simply do not load.
  • An unloaded lazy image reports width and height of zero.

That last one is the CLS connection: an image that has not loaded and has no dimensions reserves no space.

The high-value rule is about which images to lazy-load. Google’s documented guidance is unambiguous that lazy-loading above-the-fold images is “a pattern to avoid,” and their measured test is worth quoting because it quantifies the cost: disabling lazy loading for above-the-fold images improved LCP by 13% on desktop and 15% on mobile, and restricting lazy loading to below-the-fold only recovered a further 3–4%.

The recommended pattern is the opposite of the blanket attribute: eagerly load images in the initial viewport, and lazy-load the rest.

If your CMS template applies loading="lazy" to every image uniformly — which many do, because it looks like a safe optimisation — it is likely costing you LCP on the one image that decides the score.

fetchpriority, and the discipline it needs

fetchpriority is documented for images, links and scripts with three values: high, low, and auto (the default). Two documented caveats:

  • It is a hint, and prioritisation behaviour is described as “entirely browser dependent.”
  • It “should be used sparingly, as excessive or incorrect prioritization can degrade performance.”

That second point is the operational rule. Marking five images high is equivalent to marking none, because the browser cannot prioritise everything. Apply it to the single LCP image and leave the rest alone.

width and height, and the CLS they prevent

Both attributes together let the browser compute an aspect ratio before the image loads, so it can reserve the correct amount of space. The documented effect is that this reduces or prevents the layout shift that would otherwise occur when the image arrives.

This is the single cheapest performance fix available on an image-heavy page, and it does not require srcset, a build plugin, or a CDN. Two attributes, on every image.

Note the interaction with srcset: if you supply width and height, use the image’s intrinsic dimensions, not the rendered size. The browser needs the ratio, and CSS handles the rest.

<picture> versus srcset

They solve different problems, and MDN’s documentation draws the line clearly:

Use <picture> for art direction — serving a differently cropped image at different viewports, or for format alternatives like AVIF with a WebP fallback, selected via media or type.

Use srcset on the <img> for resolution switching. The documentation is explicit that for high-DPI handling you should use srcset rather than writing media conditions, because you get the browser’s own data-saver behaviour without hand-written breakpoints.

A common anti-pattern is using <picture> with media queries to serve different resolutions. That bypasses the browser’s density logic entirely, and it means you have reimplemented breakpoints the browser already had an opinion about.

A practical recipe

<!-- LCP image: eager, high priority, explicit dimensions, sized -->
<img
  src="/hero-960.avif"
  srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1440.avif 1440w"
  sizes="(max-width: 700px) 100vw, 700px"
  width="1440" height="810"
  alt="Descriptive text"
  fetchpriority="high"
>

<!-- Everything below the fold: lazy, with dimensions -->
<img
  src="/thumb-480.avif"
  srcset="/thumb-240.avif 240w, /thumb-480.avif 480w"
  sizes="(max-width: 700px) 50vw, 240px"
  width="480" height="320"
  alt="Descriptive text"
  loading="lazy"
>

The sizes value describes the layout at each breakpoint. The width/height describe the intrinsic file. Those are two different numbers and confusing them is the whole problem.

When to skip srcset entirely

  • Your images are small. If a file is 20 KB at full size, generating three variants costs more in build complexity than it saves in transfer.
  • You serve one image that is close to final size everywhere. The sizes arithmetic still helps, but the variant ladder is overkill.
  • You have not fixed the dimensions yet. width and height are the higher-value fix. Do those first, then consider variants.

The order of work matters: dimensions prevent layout shift, fetchpriority and load strategy protect LCP, and srcset reduces transfer when the browser agrees with your sizing. Do them in that order and you will never have spent effort on the smallest win first.


Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.