Articles › Web and apps

Responsive images for beginners: srcset, sizes, picture, AVIF and WebP

Images are the heaviest bytes on most home pages. Serve the right size with srcset and sizes, AVIF, WebP, JPEG via picture, and use width, height and lazy correctly.

Beginner 8 min read October 22, 2026

What you will learn
  • Resizing to the displayed size saves far more than switching formats, so do it first
  • Use picture with AVIF first, then WebP, then JPEG: the browser takes the first type it understands
  • Give every image width and height so the browser reserves the space and the page does not jump
  • loading="lazy" is for images below the first screen only, never the big image at the top

On most home pages the heaviest bytes are images, not JavaScript. The good news is that plain HTML fixes most of it, with no library: send the size the screen actually uses, pick a smaller format, and declare dimensions up front so the page does not jump. This article goes step by step, with numbers for what each step saves.

Tested on Node.js 20.14 with sharp 0.35.5 (the conversion script); browser support checked against caniuse and MDN as of October 2026.

How heavy are images?

HTTP Archive’s Web Almanac 2024 measured home pages across the web. The median image weight per page was 900 KB on mobile and 1,054 KB on desktop, out of total page weights of 2,311 KB and 2,652 KB. Images are roughly 40% of the page and outweigh JavaScript (613 KB on desktop). On inner pages JavaScript is heavier.

A large image is also often the LCP element, the biggest thing users wait to see (see Core Web Vitals). Cutting image bytes saves data and makes the page appear sooner.

srcset and sizes: send the size the screen uses

The most common problem is not the format; it is sending a 4000 px wide image to a 390 px wide screen. The fix is to produce several sizes and let the browser choose:

<img
  src="/img/hero-800.jpg"
  srcset="/img/hero-480.jpg 480w,
          /img/hero-800.jpg 800w,
          /img/hero-1200.jpg 1200w,
          /img/hero-1600.jpg 1600w"
  sizes="(max-width: 800px) 100vw, 800px"
  width="1600" height="900"
  alt="A dashboard screen in the morning">
  • srcset lists the files. The number followed by w is the file’s real width in pixels, and it must match the file
  • sizes says how wide the image will be displayed. Here: on viewports up to 800 px wide it fills the screen (100vw); on larger ones it is 800 px wide. Without sizes the default is 100vw
  • With w descriptors the browser ignores src, but keep it as a fallback

How the browser reasons: a phone with a 390 CSS px viewport and a device pixel ratio (DPR) of 3 needs 390 × 3 = 1,170 pixels for a 100vw image, so it usually picks 1200w. A laptop showing the image at 800 px with DPR 2 needs 1,600, so it picks 1600w. The browser is allowed to choose a smaller one, for example on a slow connection.

picture: AVIF first, then WebP, then JPEG

<picture> lets you offer several formats. The browser checks each <source> in order and skips any whose type it does not support; if none fits, it uses the final <img>. So put the smallest format first.

<picture>
  <source type="image/avif"
          srcset="/img/hero-480.avif 480w, /img/hero-800.avif 800w,
                  /img/hero-1200.avif 1200w, /img/hero-1600.avif 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <source type="image/webp"
          srcset="/img/hero-480.webp 480w, /img/hero-800.webp 800w,
                  /img/hero-1200.webp 1200w, /img/hero-1600.webp 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <img src="/img/hero-800.jpg"
       srcset="/img/hero-480.jpg 480w, /img/hero-800.jpg 800w,
               /img/hero-1200.jpg 1200w, /img/hero-1600.jpg 1600w"
       sizes="(max-width: 800px) 100vw, 800px"
       width="1600" height="900"
       alt="A dashboard screen in the morning"
       fetchpriority="high">
</picture>

alt, width, height, loading and fetchpriority always go on the <img>, not on <picture> or <source> (<picture> only takes global attributes). CSS such as object-fit goes on the <img> too.

One script for every size and format

This uses sharp on Node.js and was run on Node 20.14 with sharp 0.35.5:

npm install sharp
// make-images.mjs  run: node make-images.mjs
import sharp from 'sharp';

const src = 'hero.jpg';
const widths = [480, 800, 1200, 1600];

for (const w of widths) {
  const img = sharp(src).resize({ width: w, withoutEnlargement: true });
  await img.clone().avif({ quality: 50 }).toFile(`hero-${w}.avif`);
  await img.clone().webp({ quality: 75 }).toFile(`hero-${w}.webp`);
  await img.clone().jpeg({ quality: 80, mozjpeg: true }).toFile(`hero-${w}.jpg`);
}

sharp’s defaults are AVIF quality 50, WebP 80 and JPEG 80. Quality numbers are not comparable across formats; look at the results and adjust. withoutEnlargement stops small sources being upscaled into mush. Frameworks such as Astro and Next.js have image components that do this step at build time.

Worked example: a 3 MB camera photo

Say the hero is a 4000×3000 px phone photo saved as a 3,000 KB JPEG and uploaded as is. A visitor’s phone needs it 1,200 px wide.

Step 1, resize. The pixel count drops to (1200 × 900) ÷ (4000 × 3000) = 1,080,000 ÷ 12,000,000 = 0.09. If we assume bytes scale with pixels (they do not exactly; this is a rough estimate), that is 3,000 × 0.09 ≈ 270 KB.

Step 2, change format. Using the medians from ctrl.blog’s 600-image test at equal quality (equal DSSIM):

FormatMedian saving vs JPEGEstimated size
JPEG 1200 px-270 KB
WebP31.5%270 × 0.685 ≈ 185 KB
AVIF50.3%270 × 0.497 ≈ 134 KB

From 3,000 KB to about 134 KB, roughly 95% gone. But look closely: resizing alone removed 2,730 KB, and switching to AVIF removed only about 136 KB more. The order of work is clear: resize first; the format is a bonus.

Realistic ranges:

  • Google says lossy WebP is 25-34% smaller than JPEG at equal SSIM quality
  • ctrl.blog: 85% of images saved at least 20% with WebP and at least 39.6% with AVIF. No image came out larger as AVIF, while 2.7% came out larger as WebP
  • web.dev says it has seen AVIF save more than 50% over JPEG, yet the example pair in the same article differs by only about 6% (18,769 vs 20,036 bytes)

Savings always depend on the image and the encoder. Convert your own site’s images and read the real file sizes; a median is not a promise.

Camera JPEG3,000 KBResized to 1200px270 KBWebP~185 KBAVIF~134 KB
Resizing removes 2,730 KB; the format switch adds only ~136 KB more

width, height and lazy: stop the page jumping

Give every image width and height as unitless numbers (the image’s real size). The browser computes the aspect ratio and reserves the space before the file arrives, so text below is not pushed down and CLS does not grow. With CSS like img { max-width: 100%; height: auto; } the ratio from width/height still applies.

loading="lazy" makes the browser wait until the image nears the viewport. It suits images below the first screen: images in an article body, galleries. But:

  • Never on the LCP image (the big one at the top). The browser has to wait for layout to know the image is in view, so it arrives later. The default is already eager; add nothing
  • A lazy image without width/height has a size of 0 until it loads, which is one more reason to set them

decoding="async" says the next frame need not wait for the image to decode. MDN notes the effect is hard to notice on ordinary <img> elements in the page and matters more for images inserted with JavaScript. You do not need it on every image.

AttributeBig image at the top (LCP)Images below the first screen
width / heightyesyes
loadingleave out (eager)lazy
fetchpriorityhighleave out
srcset + sizesyesyes

Browser support as of October 2026

FeatureChromeEdgeFirefoxSafariGlobal users (caniuse)
WebP32+18+65+16.0+ (14-15.6 partial)~97%
AVIF85+121+93+16.4+ (16.1-16.3 partial)~96%
loading="lazy"77+79+121+16.4+~97%
fetchpriority101+101+132+17.2+-

<picture> makes this a non-issue: a browser that does not know AVIF falls through to WebP or JPEG on its own, and unknown attributes are simply ignored. The image still shows.

Common mistakes

  • loading="lazy" on every image site-wide, hero included, which makes LCP worse
  • A w value that does not match the file, e.g. 1200w on a 1000 px file, so the browser’s maths is wrong
  • Forgetting sizes, so a half-width column image downloads a full-width file
  • width/height on <source> or <picture>. They belong on the <img>
  • <source> in the wrong order. WebP above AVIF means browsers that support both always get WebP
  • The server sends the wrong Content-Type. Check with curl -sI https://example.com/img/hero-800.avif and look for content-type: image/avif
  • Never checking on a real screen. Try 375 px wide (there is a script that lets AI check it for you) and use the DevTools Network tab to see which file was actually loaded

The order with the best return: resize to fit (srcset + sizes) → width/height → AVIF/WebP in picture → lazy only below the first screen.

Sources

FAQ

Do I still need a JPEG fallback in 2026?

Keep one. As of October 2026 caniuse puts AVIF at about 96% and WebP at about 97% of users worldwide; the rest are older browsers such as Safari before 16.4 or Opera Mini. A JPEG in the final img costs almost nothing, because browsers that understand AVIF never download it.

Is AVIF always half the size of JPEG?

No. A 600-image test at equal quality found a median reduction of 50.3%, but some images saved far less, and web.dev's own example pair differs by only about 6%. Convert your own images and measure.

What happens if sizes is wrong?

The image still shows, but the browser picks the wrong file. Without sizes the default is 100vw, so an image shown at half the screen width downloads a file twice as wide as needed.

Commands and settings were tested on sample systems. Try them on a test machine before production.