Building Responsive Images: The Complete srcset & sizes Guide
A practical, no-hand-waving guide to responsive images: how srcset and sizes actually work, w vs. x descriptors, the picture element for art direction and formats, common mistakes, and how to generate the variants without maintaining them by hand.
Building Responsive Images: The Complete srcset & sizes Guide
A 2400px hero image looks gorgeous on a 27-inch monitor. Serve that same file to a 375px iPhone and the browser downloads it, decodes it, and paints it at roughly 13% of its resolution – wasting the bytes, the bandwidth, and the CPU time to decode pixels nobody will ever see. Without responsive images you're stuck choosing which group of users to penalize: ship the big file and punish mobile, or ship the small file and look blurry on desktop and Retina.
srcset and sizes resolve that by handing the browser several candidates and letting it pick. This guide explains exactly how the selection works, when to use each descriptor type, how to reach for <picture>, the mistakes that quietly break it, and how to produce all those variants without turning your repo into a variant farm.
The mental model: you describe, the browser decides
The core idea behind responsive images is a division of labor. You don't tell the browser which file to load. You give it the facts it needs – what files exist and how big each one is, and how wide the image will render in the layout – and the browser combines those with information only it has at request time (viewport width, device pixel ratio, sometimes network conditions) to choose.
That last point is why this can't be solved with CSS or media queries alone: the device pixel ratio and the actual viewport are runtime facts. srcset and sizes make the decision computable at the HTML level, before the layout even exists.
srcset with w descriptors: the workhorse
For content images that scale with the layout, use width descriptors. Each candidate is a URL followed by the file's intrinsic width in pixels:
<img
src="photo-800.jpg"
srcset="
photo-400.jpg 400w,
photo-800.jpg 800w,
photo-1200.jpg 1200w,
photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="450"
alt="...">
photo-800.jpg 800w means "this file is 800 pixels wide." Note the w is the real width of the file, not a target – you're describing your inventory, not making a request.
Here's the part people miss: with w descriptors, srcset does nothing useful without sizes. The width descriptor tells the browser how big each file is; sizes tells it how big the slot will be. It needs both to do the math.
How the browser actually picks
The selection algorithm is simple once you see it:
1. Evaluate sizes against the current viewport to get the **slot width** in CSS pixels. In the example above, a 500px-wide phone matches (max-width: 600px) → slot is 100vw = 500px. A 1400px desktop falls through to the default → slot is 800px.
2. Multiply the slot width by the device pixel ratio. That 500px slot on a 3x phone needs 500 × 3 = 1500px of actual image data.
3. From the srcset candidates, pick the smallest file whose w is ≥ the required width. Need 1500px → the 1600w file wins. If nothing is large enough, the largest available is used.
The elegance is that you never handle DPR yourself. You describe the available widths and the rendered slot; the browser factors in pixel density automatically. This is also why w descriptors beat x descriptors for anything that resizes – they adapt to viewport and density, not just density.
srcset with x descriptors: fixed-size images
When an image renders at a fixed size regardless of viewport – a logo, an avatar, an icon – viewport width is irrelevant and only pixel density matters. Use density descriptors, and skip sizes entirely:
<img
src="logo-200.png"
srcset="logo-200.png 1x, logo-400.png 2x, logo-600.png 3x"
width="200" height="50"
alt="Company logo">
The browser picks 2x on a Retina laptop, 3x on a high-density phone, 1x on a standard monitor. This is simpler than the wsizes dance, but only correct when the display size genuinely doesn't change. For everything content-shaped, use w.
One practical refinement: when you go up in density you can come down in quality. A 3x image can tolerate more aggressive compression than a 1x one because the extra pixels mask the artifacts – so a q=60 3x file can beat a q=80 2x file on both sharpness and bytes.
Reading and writing sizes correctly
sizes is a list of (media condition) width pairs, evaluated left to right, with a final bare value as the default:
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
Read it as: below 600px the image fills the viewport; below 1200px it takes half; otherwise it's 800px. The width values can be vw, px, or even calc() to account for padding and gutters – calc(100vw - 2rem) is common for full-bleed-minus-padding layouts.
Two things worth burning in:
The media conditions are not affected by DPR – only the width values are. The browser multiplies the resolved width by pixel ratio, but the media query thresholds compare against the CSS viewport. Don't try to "pre-multiply" for Retina; you'll double-count.
sizesdescribes the layout, so it has to match the layout. If your CSS renders the image at 50vw on tablet butsizessays100vw, the browser fetches images twice as large as needed. This is the single most common responsive-image bug.
The default-100vw trap
If you omit sizes while using w descriptors, the browser assumes the image occupies the full viewport width and sizes up accordingly – so it over-fetches on every multi-column or constrained layout. Omitting sizes isn't "no opinion," it's "the strongest possible opinion, and usually the wrong one." Always set it.
The <picture> element: art direction and formats
srcsetsizes answer "same image, which size?" <picture> answers two different questions.
Art direction – when you want a different crop or composition per breakpoint, not just a different scale. A wide cinematic hero on desktop, a tight square on mobile:
<picture>
<source media="(max-width: 600px)" srcset="hero-square-600.jpg">
<source media="(min-width: 601px)" srcset="hero-wide-1600.jpg">
<img src="hero-wide-1600.jpg" width="1600" height="600" alt="...">
</picture>
Format negotiation with fallback – offer modern formats and let the browser take the first it supports. The <img> inside is the mandatory fallback and the element you put alt on:
<picture>
<source type="image/avif" srcset="photo-800.avif 800w, photo-1600.avif 1600w" sizes="100vw">
<source type="image/webp" srcset="photo-800.webp 800w, photo-1600.webp 1600w" sizes="100vw">
<img src="photo-800.jpg" srcset="photo-1600.jpg 1600w" sizes="100vw"
width="1600" height="900" alt="...">
</picture>
Rule of thumb: if it's the same picture at different sizes, plain <img srcset sizes> is enough. Reach for <picture> only when you need different art per breakpoint or explicit format control with fallbacks.
Don't forget the LCP and CLS details
Responsive images sit right on top of two Core Web Vitals, so a few attributes pull double duty:
Always set
widthandheight(or a CSSaspect-ratio). They let the browser reserve the slot before bytes arrive, which prevents the layout shift that wrecks CLS. Notice every example above has them.fetchpriority="high"on your LCP image (usually the hero) tells the browser to prioritize it. Google Flights cut LCP by roughly 700ms with that one attribute – but it only helps once the bytes behind it are already small.loading="lazy"on below-the-fold images defers them so they don't compete with the hero. Never lazy-load the LCP image itself; that delays the very thing you're being measured on.
Choosing breakpoints (without going overboard)
You don't need a variant for every device. A practical set of 3–5 widths covers almost everything: 400w, 800w, 1200w, 1600w, plus 2400w only when you genuinely have full-bleed 4K-class heroes. More candidates means more files to generate, store, and cache for diminishing perceptual return – and on phone-sized screens the difference between 2x and 3x is largely imperceptible anyway, so capping content images at 2x is a reasonable default and saving 3x for critical branding.
The honest catch: someone has to make all these files
Every technique above assumes the variants exist. The markup is the easy part. The actual work is generating 400/800/1200/1600 versions of every image, in JPEG and WebP and AVIF, re-running it whenever an editor uploads something, storing every derivative, and serving it all with sane caching. Do it at build time and you own an encoding pipeline (sharp, libvips, a job queue) plus a growing pile of derivative files. Do it wrong and your srcset points at files that don't exist.
This is the part worth offloading. A transform-on-request layer lets you keep one source file and express every variant as a URL parameter, generated on demand and edge-cached. With https://fairu.app the markup above collapses to a single source plus query strings – no build step, no derivative sprawl:
<img
src="https://files.fairu.app/<asset-id>/file?width=800&format=webp"
srcset="
https://files.fairu.app/<asset-id>/file?width=400&format=webp 400w,
https://files.fairu.app/<asset-id>/file?width=800&format=webp 800w,
https://files.fairu.app/<asset-id>/file?width=1200&format=webp 1200w,
https://files.fairu.app/<asset-id>/file?width=1600&format=webp 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="450"
fetchpriority="high"
alt="...">
Each width is just a parameter; WebP is the default with AVIF available via format=avif; and because the files are produced on request and cached at the edge, you're not maintaining a variant farm or a build pipeline. You write the srcsetsizes logic from this guide once, and the bytes behind every candidate take care of themselves.
Quick reference
Content image that scales?
<img srcset>withwdescriptors andsizes. Neverwwithoutsizes.Fixed-size logo/icon?
<img srcset>withxdescriptors, nosizes.Different crop per breakpoint, or format fallbacks?
<picture>.Always set
widthheight. LCP image:fetchpriority="high", never lazy-loaded. Below the fold:loading="lazy".sizesmust match your actual CSS layout – that mismatch is the #1 bug.Keep variants to 3–5 widths; cap content images around 2x.
Get the markup right and the browser does the hard part for you. Let a transform-on-request layer like Fairu handle the files, and you never have to think about the variant matrix again. Start a free trial to point your own images at it.