Skip to content

Speed

Responsive images: srcset and sizes explained with real numbers

Stop sending a 2000-pixel photo to a 400-pixel slot. How the browser picks from srcset, why sizes is required, and a three-breakpoint example with the real pixel counts, on any CMS.

getReport teamUpdated 25 Sept 202613 min read

A product thumbnail is shown at 300 pixels wide. The file behind it is 1600 pixels wide, because that is what the designer exported and what the CMS served. The visitor downloads 28 times more image than the screen can show, on every thumbnail, on every page. srcset and sizes fix this: you provide a few sizes of each image, the browser picks the smallest one that looks sharp on its screen. This guide explains how that choice is made, with the actual numbers, and how to write the attributes so the choice is the right one.

Quick answer

  • Export each image at three or four widths (for example 400, 800, 1200 and 1600 px) and list them in srcset with w descriptors.
  • Add sizes that says how wide the slot is at each breakpoint, in CSS pixels: sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px". Without it, the browser assumes the image is full width.
  • Keep width and height on the <img> with the image's real proportions, so the space is reserved before it loads.
  • The hero image gets fetchpriority="high" and no loading="lazy"; everything below the fold gets loading="lazy".
  • A phone with a 3× screen needs 3 physical pixels per CSS pixel: a 400 CSS px slot wants a 1200 px image. That is why the biggest candidate is bigger than any desktop slot.
  • WordPress, Shopify and most frameworks write srcset for you; you usually only need to fix sizes.

Why responsive images matter

Two different sizes are in play for every image. The intrinsic size is the pixels in the file: 1600×1067. The rendered size is the box on the screen: 300×200 CSS pixels. When the file is much bigger than the box, the browser downloads the bytes, decodes them all, then throws most of them away while scaling down. On a product grid of 24 thumbnails that is easily 4 MB downloaded for 150 KB of pixels actually shown.

The screen adds a twist. CSS pixels are not device pixels. A current phone has a device pixel ratio (DPR) of 2 or 3, so a 400 CSS px slot is 800 or 1200 physical pixels wide, and an 800 px image looks slightly soft on the 3× screen. A laptop with a 2× display wants 1200 px for a 600 px slot; an office monitor at 1× wants 600. No single file is right for all of them: too small looks blurry on phones, too big wastes bytes on desktops. srcset lets you offer the set and lets each device take what it needs.

The cost lands on the Largest Contentful Paint when the oversized image is the hero, and on total page weight everywhere else. It also shows up in mobile data bills, which is why Lighthouse counts the wasted bytes as a speed opportunity.

How getReport checks it

The image size checker runs a full report and puts the images table first. The practices module requests every <img> source on the page (the src, or for lazy-load markup the data-src or the first srcset candidate), reads the first 64 KB to get the real pixel dimensions, and compares them with the width attribute in the HTML. The speed module adds Lighthouse's "Properly size images" audit, which compares what the emulated phone actually downloaded with the size it rendered at:

The images table: each file with its format, its natural pixel size, the size it is displayed at, the file size in KB, and an oversized chip on the rows served at more than twice their displayed width
Compare the two size columns; a chip marks every row where the file is more than twice as wide as the slot.

The two findings measure slightly different things, and it helps to know which is which. The oversized-dimensions check is strict and simple: the file behind src is more than 2× wider than the width attribute. It does not evaluate srcset, so an image with a good srcset but a 1600 px src still appears there; the fix is to make src a sensible mid-size candidate. Lighthouse's audit runs a real phone viewport, picks from srcset the way a phone would, and reports the bytes wasted by the candidate actually chosen; that is the number that reflects what visitors download. When only the second finding remains, sizes is usually wrong.

Step by step

1. Measure the slot, not the file

For each image the table flags, find how wide it is displayed at three viewport widths: a phone (360–430 CSS px), a tablet (768–1024), a desktop (1280 and above). In Chrome DevTools, hover the <img> in the Elements panel: the tooltip shows "Rendered size" and "Intrinsic size" side by side, and "Current source" tells you which candidate the browser chose. Resize the window and hover again.

For the example below, a product image sits in a grid: full width on phones, half width on tablets, and a fixed 600 px column on desktop.

2. Export the candidates

The largest candidate covers the biggest slot at the highest DPR you care about: a 600 px slot at 2× is 1200 px, a 430 px phone at 3× is 1290 px. A 1600 px file covers both with headroom. Below that, steps of roughly 1.5–2× keep the set small: 400, 800, 1200, 1600. Export them from the original, or let a tool do it:

Shell
# sharp-cli: four widths from one original
npx sharp-cli --input shoe.jpg --output shoe-400.webp resize 400
npx sharp-cli --input shoe.jpg --output shoe-800.webp resize 800
npx sharp-cli --input shoe.jpg --output shoe-1200.webp resize 1200
npx sharp-cli --input shoe.jpg --output shoe-1600.webp resize 1600

Use WebP or AVIF for the candidates; the format saving comes on top of the size saving (Modern image formats).

3. Write srcset with width descriptors

srcset lists each file with its real pixel width and a w:

HTML
<img src="/img/shoe-800.webp"
     srcset="/img/shoe-400.webp 400w,
             /img/shoe-800.webp 800w,
             /img/shoe-1200.webp 1200w,
             /img/shoe-1600.webp 1600w"
     sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px"
     width="800" height="533"
     alt="Blue running shoe, side view"
     loading="lazy">

The w number must be the file's actual width; the browser trusts it and never checks. src is the fallback for very old browsers and, for our checker, the file compared with the width attribute, so make it the middle candidate rather than the largest.

4. Write sizes, because the browser cannot guess

Here is the part that most templates get wrong. The browser picks the candidate while it is still parsing the HTML, before the CSS has loaded, so it does not know how wide the image will be laid out. sizes is you telling it in advance. Each entry is a media condition and a width in CSS pixels or viewport units; the first condition that matches wins; the last entry has no condition and is the default:

Text
(max-width: 600px) 100vw     phones: the image spans the viewport
(max-width: 1200px) 50vw     tablets: half the viewport
600px                        desktops: a fixed 600 px column

Without sizes, the browser assumes 100vw: full viewport width. On a 1440 px desktop with a 2× screen it then wants 2880 px for a 600 px slot and picks the largest candidate, which is exactly the waste you were trying to remove.

5. Check the arithmetic

The browser computes the slot width from sizes, multiplies by DPR, and takes the smallest candidate at least that wide (falling back to the largest if none is):

DeviceViewportsizes resultDPRWantsPicks
Phone390 px100vw = 390 px31170 px1200w
Older phone360 px100vw = 360 px2720 px800w
Tablet1024 px50vw = 512 px21024 px1200w
Laptop1440 px600 px21200 px1200w
Desktop monitor1920 px600 px1600 px800w

The 1× desktop, which used to get the 1600 px file, now gets 800 px: about a quarter of the bytes. The 3× phone gets a sharp 1200 px file instead of a soft 800. Nobody gets 1600 unless a 3× device shows the image wider than 533 CSS px, and the file is there if one does.

Browsers may also pick a smaller candidate than the arithmetic says when the connection is slow or a larger candidate is already in cache; that is allowed and fine.

6. Reserve the space with width and height

width="800" height="533" on the <img> does not fix the display size; CSS still does that (max-width: 100%; height: auto is the usual rule). What the attributes give the browser is the aspect ratio, 3:2, so it reserves a box of the right shape before the file arrives. Without them the text below jumps down when the image loads, which is the most common cause of a poor Cumulative Layout Shift. Use the proportions of any candidate; they all share the ratio.

7. Treat the hero differently

The largest image above the fold is the LCP element, and two attributes that help everywhere else hurt it. loading="lazy" delays it until layout is done, which is the opposite of what you want; leave it off. Add fetchpriority="high" so the browser fetches it before the stylesheets' fonts and the below-the-fold images:

HTML
<img src="/img/hero-1200.webp"
     srcset="/img/hero-800.webp 800w, /img/hero-1200.webp 1200w, /img/hero-1600.webp 1600w, /img/hero-2400.webp 2400w"
     sizes="100vw"
     width="1600" height="900"
     alt="Autumn collection: three runners on a trail"
     fetchpriority="high">

A full-width hero on a 2× laptop at 1440 px wants 2880 px, so the set goes higher than for a grid image. Cap it where the file gets silly: a 2400 px WebP at quality 80 is around 300 KB for a typical photo, and the 1× monitor still only takes 1600.

8. Art direction with <picture> when the crop changes

srcset serves the same picture at different sizes. When the phone should get a different crop (a tight square of the product instead of the wide lifestyle shot), use <picture> with a media condition per source; each source has its own srcset and sizes:

HTML
<picture>
  <source media="(max-width: 600px)"
          srcset="/img/hero-square-600.webp 600w, /img/hero-square-1200.webp 1200w"
          sizes="100vw">
  <img src="/img/hero-1200.webp"
       srcset="/img/hero-1200.webp 1200w, /img/hero-1600.webp 1600w, /img/hero-2400.webp 2400w"
       sizes="100vw"
       width="1600" height="900" alt="Autumn collection: three runners on a trail" fetchpriority="high">
</picture>

Unlike type sources, a media source is chosen whenever its condition matches, so the order is: most specific first, and the <img> is the default.

Platform notes

WordPress

WordPress has generated srcset and sizes for images inserted through the editor since version 4.4. Each upload gets the registered sizes (150, 300, 768, 1024, 1536 and 2048 px by default, plus any the theme adds with add_image_size()), and uploads wider than 2560 px are scaled down to that limit (big_image_size_threshold, since 5.3). Since 6.3 core also adds fetchpriority="high" to the image it judges to be the LCP and leaves lazy loading off the first images in the content.

What core cannot know is the slot width, so its default sizes is (max-width: {width}px) 100vw, {width}px, where {width} is the size you inserted. In a two-column grid that overstates the slot and phones download twice what they need. Themes fix it with the wp_calculate_image_sizes filter; for a grid of half-width cards, in functions.php or a small plugin:

PHP
// Tell the browser product-grid images are half the viewport on tablets and 400 px on desktop
add_filter('wp_calculate_image_sizes', function ($sizes, $size, $image_src, $image_meta, $attachment_id) {
    if (is_post_type_archive('product') || is_tax('product_cat')) {
        return '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 400px';
    }
    return $sizes;
}, 10, 5);

Two more things to check. Images hard-coded in the theme (<img src="<?php echo get_template_directory_uri(); ?>/img/hero.jpg">) get no srcset at all; use wp_get_attachment_image() with a media library image instead. And if you added sizes after uploading, existing images do not have them; the Regenerate Thumbnails plugin creates the missing files.

Elementor's Image widget has an "Image Resolution" setting (Thumbnail, Medium, Large, Full or a custom size): choosing "Full" is what puts a 2560 px file in a 300 px card. Set it to the size closest to the slot; Elementor still writes srcset from the registered sizes.

Shopify

Liquid's image_tag filter writes srcset from the widths you list and takes sizes as a parameter:

Liquid
{{ product.featured_image
   | image_url: width: 1600
   | image_tag: widths: '400, 800, 1200, 1600',
                sizes: '(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px',
                loading: 'lazy' }}

The CDN generates each width on request. Dawn-based themes already do this for product cards; check the sizes values in card-product.liquid against your actual grid.

Static sites and frameworks

Astro's <Image> and <Picture>, Next.js next/image and Eleventy's image plugin all generate the candidates at build time and write srcset. Every one of them takes a sizes prop, and every one of them defaults to 100vw when you leave it out. The prop is the fix.

Verify

  • Re-run the image size checker. The oversized-dimensions finding reads "Every sized image is served close to its displayed size", and Lighthouse's image-sizing finding reads "Images are sized for how they are displayed".
  • In Chrome DevTools, set the device toolbar to a phone (for example a 390 px viewport at DPR 3), reload, and hover an <img> in the Elements panel. "Current source" should name the 1200 px candidate, and "Intrinsic size" should be close to "Rendered size" times 3.
  • Switch to a 1920 px desktop at DPR 1 and hover again: the 800 px candidate.
  • In the Network panel, filtered by "Img", the total transferred for the page on the phone profile drops, typically by half on a product grid.

Common mistakes

  • srcset without sizes. The browser assumes full width and picks the biggest candidate on desktops. The report's Lighthouse finding stays red while the oversized-dimensions finding passes. Add sizes.
  • sizes copied from another layout. A sizes that says 100vw on a three-column grid downloads three times the pixels. Measure the slot in DevTools at each breakpoint.
  • A w descriptor that lies. A file renamed -1200 but actually 600 px wide looks blurry on phones, because the browser believes the number. Generate the candidates from the original with a tool, never by renaming.
  • Lazy loading the hero. loading="lazy" on the LCP image adds hundreds of milliseconds. Only images below the fold get it; the hero gets fetchpriority="high".
  • width and height from the wrong candidate. They only need the right ratio, but width="1600" height="533" on a 3:2 image reserves the wrong box and shifts the layout twice. Use one candidate's real dimensions.
  • Uploading the 6000 px camera file and relying on srcset. Core scales to 2560 px, and every candidate is generated, but the original still sits in the media library and gets linked by "Full size" settings. Resize to 2400 px before upload.
Check your site before and after Check