Responsive Images: srcset, sizes and the Formats Worth Using
How srcset, sizes and the picture element actually work, which widths to generate, and whether AVIF or WebP wins. Responsive images done by practitioners.
A client sent over a redesign for a project archive: twelve cards, three across on desktop. Lighthouse flagged 4.8MB of images on a page whose largest visible image slot was 420px wide. Every card had a perfectly valid srcset. The problem was one attribute: sizes="100vw", copied from the hero, which told every browser on every device to pick the 1800px file for a thumbnail.
That’s the thing about responsive images. The markup looks correct, it validates, nothing breaks visually, and you ship three times the bytes you needed. There’s no error message for “you lied to the browser about layout”.
Here’s what we actually do on production sites in 2026: how the selection algorithm works, how many widths to generate (fewer than most build tools default to), which formats earn their encode time, and the handful of images that still deserve manual attention.
sizesis the attribute that decides your payload, notsrcset. It must describe the rendered slot width at each breakpoint, and the browser reads it before CSS is applied, so it cannot be inferred for you.- Four to five widths per image is plenty. Space them roughly 1.4x to 1.5x apart rather than at your CSS breakpoints, and cap the top width at about 2x the largest slot.
- AVIF typically lands 25% to 45% smaller than WebP at matched visual quality on photographic content, but it has no progressive rendering, which makes it a worse choice for a large LCP hero on slow connections than a progressive JPEG or a smaller AVIF.
- Use
loading="lazy"plussizes="auto"for everything below the fold, and never on the LCP image, which wantsfetchpriority="high"and an explicitsizes. - Always set
widthandheightattributes. They cost nothing and remove the layout shift that image-heavy pages otherwise bake in.
What the browser actually does with srcset and sizes
The selection runs in a specific order, and knowing it explains most of the confusing behaviour. The browser resolves sizes into a single CSS pixel length for the current viewport. It multiplies that by the device pixel ratio to get a target in device pixels. Then it walks the srcset candidates and picks the smallest one whose w descriptor is at or above the target.
Two consequences people trip over. First, this happens during HTML parsing, before stylesheets have necessarily been applied. That’s why the browser cannot work out your layout itself, and why sizes exists at all. Second, the w value is a promise about the file’s intrinsic width. Resize images and forget to update the descriptors, and selection goes quietly wrong in both directions.
Use x descriptors only for images whose CSS size never changes: logos, avatars, icons in a fixed-height header. srcset="logo.png 1x, [email protected] 2x" with no sizes is correct and complete there. Everything fluid wants w descriptors.

Responsive images break at the sizes attribute
Write sizes by reading your own CSS, media condition first, fallback last. The first matching condition wins, so the order must go narrow to wide.
A three-up card grid in a Bootstrap 5 container (1320px at xxl, 24px gutters) renders each image at roughly 420px. On tablet it’s two-up, so about half the viewport. On phones it’s full width minus container padding. Three states, and the attribute writes itself:
<img
src="/img/project-800.jpg"
srcset="/img/project-400.jpg 400w,
/img/project-600.jpg 600w,
/img/project-900.jpg 900w,
/img/project-1200.jpg 1200w"
sizes="(max-width: 575px) calc(100vw - 24px),
(max-width: 991px) 48vw,
420px"
width="1200" height="800"
loading="lazy" decoding="async"
alt="Riverside clinic reception, timber slats and warm lighting">
Note the calc(100vw - 24px). Container padding matters at the small end, because 100vw on a 390px phone at DPR 3 asks for 1170 device pixels and will jump you to the 1200w file; subtracting the padding lands you on 900w instead. That one change cut about 30% off the image bytes on the archive page I mentioned at the top.
sizes doesn’t have to be exact. Being 10% generous is harmless. Being 100vw when the slot is 35vw is the failure mode, and it’s the single most common responsive images bug we find on inherited codebases. If you’re on a framework or template with a known grid, write the sizes strings once per component and treat them as part of the component contract. The Bootstrap-based layouts in Canvas all use fixed container widths, which means the right-hand fallback value is a plain pixel number you can look up rather than guess.
When to let the browser do it: sizes=”auto”
sizes="auto" is in the HTML spec and works only on images with loading="lazy", because lazy images are laid out before they’re fetched, so the browser genuinely knows the slot width. Chromium shipped it in early 2024, Firefox followed later that year, and Safari has been slower. Browsers without support fall back to the sizes value you supply after it.
<img loading="lazy" sizes="auto, (max-width: 991px) 50vw, 420px" srcset="..." ...>
This is the right default for grids, blog listings, and anything below the fold. It’s not available for the hero, which is exactly the image where the stakes are highest.
How many widths, and which ones
Build tools love to generate ten variants. That’s wasted CI time and wasted cache space. The real goal is that no visitor downloads a file more than about 25% wider than they need. A 1.4x to 1.5x step between widths achieves that with four or five files.
Work from the slot, not from the breakpoints. For the 420px card above, the largest useful file is 420 times 2 (DPR cap) = 840, so 900w is the top. Below that, 600 and 400 cover tablet and phone. A full-bleed hero on a 1920px display at DPR 2 theoretically wants 3840px. Refuse it. Cap at 2000px to 2400px and accept that a 3x phone gets a slightly soft image. Nobody has ever filed a bug about that. They do file bugs about 1.8MB heroes.
The counter-move for high-DPR screens is to lower quality as you raise resolution. At DPR 3 the per-pixel artefacts are below the resolving limit of the display, so a 2000px AVIF at quality 40 looks cleaner than a 1000px AVIF at quality 70 and often weighs less. We use this on retina-heavy product photography.
import sharp from 'sharp';
const widths = [400, 600, 900, 1400];
for (const w of widths) {
const base = sharp('src/project.jpg').resize({ width: w, withoutEnlargement: true });
// effort 4 keeps AVIF encoding near a second per megapixel; effort 9 is roughly
// four times slower for about 3% smaller files, which is not worth it in CI
await base.clone().avif({ quality: 52, effort: 4 }).toFile(dist/project-${w}.avif);
await base.clone().webp({ quality: 76 }).toFile(dist/project-${w}.webp);
await base.clone().jpeg({ quality: 80, mozjpeg: true, progressive: true }).toFile(dist/project-${w}.jpg);
}
Wire that into whatever you already run. If you’re on Vite, vite-imagetools does the same work with query-string imports and caches results between builds, which matters once you have a few hundred source images. We covered where that fits against Gulp and plain files in build tooling for static templates.
The formats worth using: webp, avif and the awkward cases
Browser support settled a while ago. WebP is universal. AVIF works in Chrome, Firefox and Safari 16.4 and up, which in practice means everything you’ll see in analytics except elderly iOS devices. The decision is no longer about support. It’s about which format wins per image type.
- AVIF for photographs, gradients, illustrations with flat colour, and anything with large smooth regions. It handles gradients without banding (use 10-bit output if you see any) and typically beats WebP by 25% to 45% at matched visual quality.
- WebP for small thumbnails (under about 200px, where AVIF’s container overhead eats the gain), for images with film grain or fine noise that AVIF smears, and as the universal fallback instead of JPEG if you don’t care about iOS 13.
- JPEG, progressive, mozjpeg as the last fallback, and genuinely the better choice for one specific case: a very large hero where you want something on screen fast. AVIF and WebP both paint all-or-nothing. A progressive JPEG shows a recognisable image at roughly a third of its bytes.
- SVG for logos, icons and diagrams. No
srcset, no variants, inline it if it’s small and reused. - JPEG XL is still a Safari-only story on the web. Encode it if your pipeline is cheap, put it first in the
picture, and don’t plan around it.
One warning about AVIF encode cost. At effort: 6 and above, a 2400px image can take several seconds per variant. On a site with 600 products that’s a CI job that times out. Either cap effort at 4 or move encoding to an image CDN and stop generating variants at build time.
picture element or Accept negotiation
Two ways to serve modern formats. The picture element lists sources and the browser takes the first type it supports:
<picture>
<source type="image/avif" sizes="auto, 100vw"
srcset="/img/hero-800.avif 800w, /img/hero-1400.avif 1400w, /img/hero-2000.avif 2000w">
<source type="image/webp" sizes="auto, 100vw"
srcset="/img/hero-800.webp 800w, /img/hero-1400.webp 1400w, /img/hero-2000.webp 2000w">
<img src="/img/hero-1400.jpg" width="2000" height="1125"
fetchpriority="high" alt="...">
</picture>
Order matters and there’s no fallback inside a source: if a browser understands image/avif it commits to that source even if the file 404s. Also, sizes and srcset go on each source, not just the img. That’s a mistake worth flagging because it’s easy to get wrong.
The alternative is content negotiation: one URL, and the CDN inspects the Accept header and returns AVIF, WebP or JPEG with Vary: Accept. Cloudflare Images, Netlify Image CDN, imgproxy and Cloudinary all do this. Markup stays one img tag, which is a real maintenance win on a CMS. The cost is a cache key split and less control over per-format quality.
Our rule: negotiation for CMS-driven content where authors upload whatever they like, explicit picture for designed pages where you want art direction (different crops per breakpoint) or dark mode variants via media="(prefers-color-scheme: dark)". picture is the only way to change the crop. Format fallback is the side hustle.
The one image that needs hand attention
Your LCP element is almost always an image, and it breaks every rule above. It must not be lazy. It wants fetchpriority="high" so it jumps ahead of the stylesheet-blocked queue. It needs real sizes because auto isn’t available without lazy loading. And it should be the smallest file you can justify, because every kilobyte here is on the critical path.
If the hero is a CSS background, inside a carousel, or injected by JS, the preload scanner can’t find it and you’ve lost 300ms to 600ms before the request even starts. Fix it with a preload that mirrors the markup exactly:
<link rel="preload" as="image"
imagesrcset="/img/hero-800.avif 800w, /img/hero-1400.avif 1400w, /img/hero-2000.avif 2000w"
imagesizes="100vw" type="image/avif" fetchpriority="high">
If the imagesizes here and the sizes in your markup disagree, you’ll download two files. Check the network panel. This pairs with the rest of the critical-path work covered in eliminating render-blocking resources, and on commerce sites the same discipline applies to gallery thumbnails, which we picked apart in WooCommerce speed.
WordPress defaults and where they go wrong
WordPress has generated srcset automatically since 4.4 and has supported sizes="auto" on lazy images since 6.7, so the format story is mostly handled. The sizes story is not. The default is (max-width: 1024px) 100vw, 1024px, derived from the image’s own dimensions and nothing to do with your theme’s layout. On a 3-up archive grid it’s wrong by a factor of three.
addfilter( 'wpcalculateimagesizes', function ( $sizes, $size, $src, $meta, $attachment_id ) {
// Project cards sit 3-up inside a 1320px container, so ~420px on desktop
if ( isposttypearchive( 'project' ) || istax( 'project-category' ) ) {
return 'auto, (max-width: 575px) calc(100vw - 24px), (max-width: 991px) 48vw, 420px';
}
return $sizes;
}, 10, 5 );
Also prune your registered sizes. A stock install plus a theme plus three plugins can produce eleven variants per upload, most of which nothing ever requests, and they all sit in uploads forever. Audit with wp media regenerate --dry-run and remove what no template calls. If you’re modelling archives with custom post types, decide the card dimensions at the same time you decide the fields. Our content modelling guide covers that ordering.
Verifying what actually shipped
Don’t trust the markup, check currentSrc. Paste this into the console on a real page at a real viewport width:
// Flags images where the downloaded file is noticeably wider than the slot needs
[...document.querySelectorAll('img')].forEach(img => {
const need = Math.round(img.clientWidth * devicePixelRatio);
const got = img.naturalWidth;
if (need > 0 && got / need > 1.3) {
console.warn(${got}px file in a ${need}px slot, img.currentSrc || img.src, img);
}
});
Run it at 390px, 768px and 1440px. Three widths, thirty seconds, and it catches the sizes="100vw" copy-paste every time. In DevTools, hovering a network entry shows intrinsic size against resource size, and Lighthouse’s “properly size images” audit finds the same thing with less precision. Also test with DPR forced to 1 in device emulation, because a 2x laptop hides a lot of oversizing.
Frequently Asked Questions
Do I still need a JPEG fallback in 2026?
For most audiences, no. Safari 16.4 shipped AVIF in March 2023, so an AVIF source with a WebP fallback covers effectively everyone, and WebP has worked since Safari 14. Keep a JPEG only if your analytics show meaningful iOS 13 or older Android traffic, or if you want progressive rendering on a large hero.
Should I use picture or just srcset on an img?
Use plain img with srcset and sizes whenever the image is the same crop at every size, which is most images. Reach for picture when you need a different crop per breakpoint, a dark mode variant, or explicit per-format control. If your CDN negotiates formats from the Accept header, you may never need picture at all.
Why is my browser downloading the biggest image even on mobile?
Nine times out of ten, your sizes value overstates the slot. Check img.currentSrc in the console and compare the file width to clientWidth times devicePixelRatio. The other cause is a missing sizes attribute entirely, which defaults to 100vw.
Is AVIF ever a bad idea?
Yes, in three cases. Small thumbnails under roughly 200px, where container overhead cancels the compression gain. Grainy or noisy photography, where AVIF’s denoising flattens texture you wanted. And very large hero images on slow connections, where the lack of progressive decoding means a blank box until the whole file lands.
How many image variants should I generate per source file?
Four or five widths per format, spaced about 1.4x apart, covering from your smallest slot up to twice your largest. More than that adds build time and cache pressure for savings you can’t see. If you have thousands of images, skip build-time generation and use an image CDN with on-demand resizing instead.
Where to start
Run the console snippet above on your three busiest templates right now. If it prints nothing, your responsive images are fine. If it prints a dozen warnings, fix the sizes attributes first, before you touch formats. A correct sizes on a JPEG beats a wrong sizes on an AVIF almost every time. Then add AVIF, cap your top width, and give the LCP image the ten minutes of individual attention it’s owed.


