A product photo displayed 300px wide in a three-column grid doesn’t need the 2400px original. On a phone with a 3× screen it does need about 900 pixels to look sharp. On a 1× office monitor, 300 is enough. Sending everyone the same file either wastes megabytes on cheap mobile data or looks blurry on good screens.
The srcset and sizes attributes let the browser pick the right file. The catch is that it has to pick before it knows the layout: the preload scanner starts image requests while the CSS may still be downloading. So you have to tell the browser, in HTML, how big the image will be. That’s the part people get wrong.
Density descriptors: fixed-size images
For an image that is always the same CSS size (a 48px avatar, a logo), list files by pixel density:
<img
src="/img/avatar-48.png"
srcset="/img/avatar-48.png 1x, /img/avatar-96.png 2x, /img/avatar-144.png 3x"
alt="Ana Silva"
width="48"
height="48"
>The browser compares its device pixel ratio (DPR) with the descriptors and picks one: 1× screens get the 48px file, a MacBook gets 96, a recent iPhone gets 144. src is the fallback for very old browsers and also the default 1x candidate.
Width descriptors and sizes: fluid images
For an image whose displayed width changes with the layout, describe each file by its actual pixel width with w, and describe the layout with sizes:
<img
src="/img/shoe-800.jpg"
srcset="
/img/shoe-400.jpg 400w,
/img/shoe-800.jpg 800w,
/img/shoe-1200.jpg 1200w,
/img/shoe-1600.jpg 1600w"
sizes="(min-width: 1024px) 33vw, 100vw"
alt="Trail Runner 3, side view"
width="1600"
height="1200"
>sizes is a list of media conditions with the image’s display width for each, evaluated left to right, with the last entry as the default. This one says: on viewports at least 1024px wide, the image is a third of the viewport (the three-column grid); otherwise it’s full width. These widths must match your CSS. The browser trusts them; it doesn’t check.
The browser then does this calculation:
- Find the slot width from
sizes. - Divide each candidate’s width by the slot width. That gives each file’s effective density.
- Pick a candidate whose density is close to the screen’s DPR, usually the smallest that’s at least as dense.
Here it is on three devices:
device slot densities 400/800/1200/1600w picked
phone, 390px wide, DPR 3 100vw = 390px 1.03 / 2.05 / 3.08 / 4.10 1200w
tablet, 768px wide, DPR 2 100vw = 768px 0.52 / 1.04 / 1.56 / 2.08 1600w
laptop, 1440px wide, DPR 1 33vw = 475px 0.84 / 1.68 / 2.53 / 3.37 800wThe phone needs about 3.0 and 1200w gives 3.08. The tablet needs 2.0 and only 1600w reaches it. The laptop needs 1.0; 400w is slightly too small (0.84) so 800w is chosen. The spec leaves the final choice to the browser, which may also consider network conditions or what’s already in the cache: if the 1600w file is cached, a browser can reuse it instead of fetching a smaller one. Chrome, for instance, uses a geometric-mean rule between neighbouring candidates, so near a boundary it can pick the slightly smaller file. Treat the table as the typical outcome, not a guarantee.
Two practical consequences. First, generate candidates at reasonable steps (roughly every 300 to 400 pixels, or by file size increments of 20 to 30 KB); too few and many devices download much more than they need. Second, once a browser has a larger version cached, it won’t swap to a smaller one when the window shrinks, so don’t be surprised when resizing DevTools doesn’t trigger a new request.
The sizes mistake
Leave sizes out with w descriptors and the browser assumes 100vw. For the laptop above, that means a 1440px slot at DPR 1, so it fetches the 1600w file for an image displayed at 475px, more than three times the pixels it needs, for every card in the grid. Lighthouse flags this as “Properly size images”. It’s easily the most common responsive-image bug, and frameworks reproduce it when a component’s sizes prop is never set.
You can be approximate: sizes that’s a few percent off costs little. Being off by a factor of three is what hurts. If your layout has padding and gaps, calc() is allowed: sizes="(min-width: 1024px) calc((100vw - 96px) / 3), calc(100vw - 32px)".
Recent Chromium versions also support sizes="auto" for lazy-loaded images (loading="lazy" is required), letting the browser use the real laid-out width because by the time a lazy image loads, layout is known. Browsers that don’t support it fall back to the next entry if you write sizes="auto, (min-width: 1024px) 33vw, 100vw".
picture: different crops and formats
srcset assumes every candidate is the same image at different resolutions. When the image itself should change, use <picture>.
Art direction: a wide banner on desktop and a tighter square crop on phones, where the subject would otherwise be tiny:
<picture>
<source
media="(max-width: 600px)"
srcset="/img/hero-square-600.jpg 600w, /img/hero-square-1200.jpg 1200w"
sizes="100vw"
width="1200"
height="1200"
>
<img
src="/img/hero-wide-1600.jpg"
srcset="/img/hero-wide-1600.jpg 1600w, /img/hero-wide-2400.jpg 2400w"
sizes="100vw"
alt="Runner crossing a ridge at sunrise"
width="2400"
height="1000"
>
</picture>The browser takes the first <source> whose media matches and must use it (unlike srcset choices, media is a command, not a hint). If none matches, it uses the <img>. The width and height on <source> let each crop reserve the right aspect ratio, so the layout doesn’t shift when switching between them.
Format selection: serve AVIF or WebP where supported, JPEG elsewhere:
<picture>
<source type="image/avif" srcset="/img/shoe-800.avif 800w, /img/shoe-1600.avif 1600w" sizes="(min-width: 1024px) 33vw, 100vw">
<source type="image/webp" srcset="/img/shoe-800.webp 800w, /img/shoe-1600.webp 1600w" sizes="(min-width: 1024px) 33vw, 100vw">
<img src="/img/shoe-800.jpg" srcset="/img/shoe-800.jpg 800w, /img/shoe-1600.jpg 1600w" sizes="(min-width: 1024px) 33vw, 100vw" alt="Trail Runner 3, side view" width="1600" height="1200">
</picture>The browser skips sources whose type it can’t decode. Note that the <img> is always required: it’s the element that actually renders. alt, loading, fetchpriority, width, height and CSS all go on the <img>, not on <picture>.
Checking which file loaded
In DevTools, select the image in the Elements panel and hover over it: Chrome shows the rendered size and the intrinsic size of the file that loaded. $0.currentSrc in the console gives the chosen URL. The Network panel, filtered to Img, shows the size of each download. Use the device toolbar with a DPR of 3 to check phones, but reload after changing it, because a cached larger file will be reused otherwise.
Interview practice
A blog’s article images are displayed at a maximum of 720px wide in a centred column (full width minus 32px of padding on small screens). The template outputs srcset with 480w, 960w, 1440w and 2160w files and no sizes. Field data shows tablet and desktop users downloading the 2160w file. Explain why and write a correct sizes.
Answer
Without sizes, the browser assumes the image fills the viewport, which is roughly true on phones: a 412px phone at DPR 2.625 needs about 1082 pixels and gets the 960w or 1440w file, depending on the browser’s rounding (Chrome’s rule picks 960w here). The waste starts once the viewport is wider than the 720px column. A 1024px tablet at DPR 2 needs 2048 pixels for a “100vw” image, so it fetches the 2160w file for an image displayed 720px wide. A 1920px desktop monitor at DPR 1 does the same, and so does any laptop at DPR 2.
The fix is to describe the real layout: sizes="(min-width: 752px) 720px, calc(100vw - 32px)", where 752 is 720 plus the 32px of padding, the point at which the column stops growing. Now the tablet’s slot is 720px, needing 1440 pixels at DPR 2, so it gets the 1440w file, about 44% of the pixels of the 2160w one. The desktop at DPR 1 needs 720 and gets 960w. The 412px phone’s slot becomes 380px, needing about 998 pixels, and it gets 960w. Verify with $0.currentSrc on a few emulated devices, reloading after each change of device so the cache doesn’t hide the difference.