Serving a single image file to every device — a 2400px wide JPEG compressed for desktop monitors — has a measurable cost. A phone on a slow mobile connection downloads far more data than it can render, burning bandwidth and battery. At the other end, a high-density display receiving a 1x image renders it blurry, because the browser stretches it to meet pixel density. The srcset attribute and the <picture> element are the HTML response to both problems. They are distinct problems, and the solutions, while related, work differently.
Two Separate Problems
Pixel density and viewport width are often conflated in tutorials that introduce responsive images, but they require different mental models.
Density is about the ratio between CSS pixels and physical device pixels. A “2x” display (common on modern phones and laptops) has four physical pixels for every CSS pixel. An img rendered at 400×300 CSS pixels needs an 800×600 source image to appear sharp. If you only have a 400×300 source, the browser scales it up and the result looks soft.
Viewport width is about layout. A card image displayed at 100% viewport width on desktop needs a wide file. The same image in a two-column grid at the same viewport needs half that. You need different files — not because the screen is denser, but because the image’s display size changes with layout.
The srcset attribute handles both, using two different descriptor syntaxes: x descriptors for density, w descriptors for width. Confusing them is the source of most srcset mistakes. The w descriptor case in particular only works correctly alongside a sizes attribute that reflects the same breakpoints defined in your media queries — if the layout’s actual rendered width at a given viewport doesn’t match what sizes claims, the browser selects the wrong candidate.
Density Descriptors: The Simpler Case
When an image always renders at a fixed CSS size — an avatar, a logo, an icon — you know the display dimensions ahead of time. You can provide one file per density tier:
<img
src="avatar-80.jpg"
srcset="avatar-80.jpg 1x, avatar-160.jpg 2x, avatar-240.jpg 3x"
alt="Profile photo of a conference speaker"
width="80"
height="80"
/>
The browser evaluates window.devicePixelRatio and picks the matching candidate. A 1x device loads avatar-80.jpg. A Retina display at 2x loads avatar-160.jpg. There is no calculation involved — the descriptor is a direct multiplier match.
Note that src still exists as a fallback. Browsers that do not understand srcset fall back to src. Always supply it.
The x descriptor is appropriate when the rendered CSS size of the image does not change relative to the layout. The moment the image’s CSS size is responsive — stretching or collapsing with the viewport — you need w descriptors.
Width Descriptors and the sizes Attribute
Width descriptors describe the intrinsic pixel width of each image file. They tell the browser what you have, not what to display:
<img
src="photo-800.jpg"
srcset="
photo-400.jpg 400w,
photo-800.jpg 800w,
photo-1200.jpg 1200w,
photo-2400.jpg 2400w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
alt="Mountain range at golden hour"
width="1200"
height="800"
/>
The sizes attribute is what most tutorials skip over or underexplain. Without it, the browser has no idea how large the image will appear in your layout, so it cannot calculate which candidate is the right file to fetch. The browser must make this decision before CSS is parsed — early in the network waterfall — so it cannot infer layout size from your stylesheets.
How the Browser Calculates Which File to Load
The sizes attribute is a comma-separated list of media condition / length pairs, evaluated top to bottom. The browser finds the first matching condition and uses that length as the image’s expected display width. The last item in the list is the default — no media condition required.
For the example above:
- Viewport ≤ 600px: image will be
100vwwide - Viewport ≤ 1200px: image will be
50vwwide - Otherwise: image will be
800pxwide
The browser then multiplies the expected display width by devicePixelRatio to get the required pixel count, and picks the smallest candidate file that meets or exceeds that requirement.
Concrete math: a 375px viewport at 2x device pixel ratio needs 375 × 2 = 750 real pixels. The sizes condition matches 100vw = 375px, effective pixel need is 750. Looking at the srcset, photo-400.jpg at 400w is too small. photo-800.jpg at 800w is sufficient. The browser fetches photo-800.jpg.
Same image, 1440px viewport, 1x display: sizes gives 800px (default). 800 × 1 = 800. The browser picks photo-800.jpg.
Same image, 1440px viewport, 2x display: 800 × 2 = 1600 required. The browser skips photo-800.jpg and picks photo-1200.jpg — the smallest candidate at or above 1600… except 1200 < 1600, so it reaches for photo-2400.jpg.
This is the calculation that matters. If your sizes attribute does not accurately reflect your CSS layout, the browser makes decisions based on wrong premises. Inaccurate sizes is not a syntax error — it silently causes under-serving (blurry images on high-density displays) or over-serving (unnecessarily large downloads).
Writing Accurate sizes Values
The sizes value should mirror your CSS breakpoints and image sizing rules. If your layout does this:
.hero-image {
width: 100%;
max-width: 1200px;
}
@media (min-width: 768px) {
.card-image {
width: calc(50% - 1rem);
}
}
Then a card image’s sizes might be:
sizes="(min-width: 768px) calc(50vw - 1rem), 100vw"
The calc() function is valid inside sizes. You can use vw, px, em, and calc() — but not percentage values directly, because percentages are relative to the containing block and the browser does not know the containing block size before layout.
The picture Element: Art Direction
The srcset and sizes combination on <img> handles resolution switching — serving the same image at different resolutions and sizes. It does not handle art direction: serving a fundamentally different crop or composition at different viewport sizes.
Consider a hero image: on desktop, a wide landscape shot showing a full mountain panorama. On mobile, a tightly cropped portrait showing the peak alone. These are compositionally different images, not just different sizes of the same image. For this, you need <picture>:
<picture>
<source
media="(min-width: 800px)"
srcset="hero-wide-1200.jpg 1200w, hero-wide-2400.jpg 2400w"
sizes="100vw"
/>
<source
media="(min-width: 400px)"
srcset="hero-medium-800.jpg 800w, hero-medium-1600.jpg 1600w"
sizes="100vw"
/>
<img
src="hero-square-400.jpg"
srcset="hero-square-400.jpg 400w, hero-square-800.jpg 800w"
sizes="100vw"
alt="Summit of a granite peak above the treeline"
width="400"
height="400"
/>
</picture>
The browser evaluates <source> elements in order. When a media condition matches, the browser uses that source’s srcset and sizes to select a file. The <img> at the end is mandatory — it acts as both the fallback for browsers that do not support <picture> and the element that carries alt, width, height, and other attributes that apply regardless of which source is chosen.
The <source> element’s media attribute mirrors CSS media queries. When the condition is true, the browser stops evaluating further sources. Order matters: put the most specific or largest conditions first, or structure them so they are mutually exclusive.
Note that art direction is a content decision, not a performance one. If your images are the same composition at different sizes, srcset and sizes on <img> is sufficient and simpler. Reserve <picture> for cases where the framing, subject, or aspect ratio actually changes.
For more on semantic markup decisions that interact with images, see the HTML semantics article.
Format Negotiation with type
<picture> also solves a different problem: serving next-generation image formats to browsers that support them, while falling back to JPEG or PNG for older browsers.
<picture>
<source
type="image/avif"
srcset="photo-800.avif 800w, photo-1600.avif 1600w"
sizes="(max-width: 800px) 100vw, 800px"
/>
<source
type="image/webp"
srcset="photo-800.webp 800w, photo-1600.webp 1600w"
sizes="(max-width: 800px) 100vw, 800px"
/>
<img
src="photo-800.jpg"
srcset="photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(max-width: 800px) 100vw, 800px"
alt="Close-up of hand-thrown ceramic with ash glaze"
width="800"
height="533"
loading="lazy"
decoding="async"
/>
</picture>
The browser evaluates <source> elements top to bottom and picks the first one whose type it supports. A browser with AVIF support loads the AVIF file. A browser with WebP but not AVIF loads the WebP. Older browsers fall through to the <img> and its JPEG src.
AVIF typically achieves 50% smaller file sizes than JPEG at comparable quality. WebP achieves roughly 25–35% savings. Browser support for AVIF now covers all major browsers as of 2024, but if your analytics show significant traffic from older environments, the fallback chain is worth maintaining.
Combine type and media on the same <source> when you need both art direction and format negotiation. Stack your <source> elements so the most preferred format appears first within each breakpoint group, or restructure by media condition first and nest format variants within each — either approach works as long as the browser encounters the most capable combination first.
loading and decoding
Two attributes work alongside srcset and <picture> to manage performance:
loading="lazy" defers image loading until the image is near the viewport. The browser will not fetch the file until the user scrolls close to where the image appears. This is appropriate for most images below the fold. Do not apply it to images that are visible on initial page load — the delay will cause a visible pop-in and hurt Largest Contentful Paint if the image is the LCP element.
decoding="async" hints that the browser should decode the image off the main thread. This prevents image decoding from blocking rendering or interactivity. The browser may ignore the hint, but when honored it can reduce jank during scrolling. Apply it broadly — there is minimal downside.
Neither attribute changes which file the browser selects. They affect when the file is fetched and when it is decoded, not which candidate wins the srcset evaluation.
width and height are worth mentioning here as well. Specifying both on <img> allows the browser to calculate the image’s aspect ratio before the file loads, reserving layout space and eliminating Cumulative Layout Shift. This applies even for responsive images — the ratio is preserved, and CSS can override the actual rendered size. See the W3C HTML spec on the sizes attribute for the normative definition of how candidates are evaluated.
Putting It Together
A practical card image in a two-column grid above 768px, supporting AVIF and WebP with lazy loading, might look like:
<picture>
<source
type="image/avif"
srcset="card-400.avif 400w, card-800.avif 800w"
sizes="(min-width: 768px) calc(50vw - 2rem), 100vw"
/>
<source
type="image/webp"
srcset="card-400.webp 400w, card-800.webp 800w"
sizes="(min-width: 768px) calc(50vw - 2rem), 100vw"
/>
<img
src="card-800.jpg"
srcset="card-400.jpg 400w, card-800.jpg 800w"
sizes="(min-width: 768px) calc(50vw - 2rem), 100vw"
alt="Conference room with glass walls overlooking a city skyline"
width="800"
height="533"
loading="lazy"
decoding="async"
/>
</picture>
The sizes value calc(50vw - 2rem) accounts for a 1rem gap on each side of the two-column grid. This keeps the browser’s candidate math accurate. Without the gap adjustment, the browser might fetch a slightly larger file than necessary.
Tooling like Squoosh, sharp in Node.js, or image CDNs with transformation APIs (Cloudinary, Imgix, Cloudflare Images) can generate the required file variants at build time or on demand. The HTML syntax above is format-agnostic — the mechanism works regardless of how you produce the files.
For considerations around how font loading affects perceived performance in a similar way, see the web fonts guide.
The WHATWG HTML Living Standard and MDN’s responsive images guide remain the authoritative references for edge cases and the normative selection algorithm.



