All guidesPerformance

srcset and sizes: How to Stop Shipping Desktop Images to Phones

Responsive images are the largest single saving available on most sites, and the sizes attribute is wrong on most of the ones that have implemented them. A practical guide to srcset, sizes, picture and the breakpoint maths.

August 9, 20268 min read
Empty wooden picture frames of descending sizes leaning nested against a pale wall in a sunlit room

Most sites that have implemented responsive images are not getting the benefit, and the reason is almost always the same one attribute.

The srcset half is easy and usually correct, because build tools and CMSes generate it automatically. The sizes half describes your layout, which no tool can infer, so it is either omitted or left at a default that assumes every image is full-bleed. The browser believes it, picks a file sized for the whole viewport, and the saving evaporates.

This is worth fixing properly because on an image-heavy page it is the largest single byte saving available — larger than switching format, larger than tuning quality — and unlike most performance work it does not degrade anything visually.

What the browser is actually deciding

Before any of the syntax makes sense, it helps to know the calculation happening.

When the browser encounters an image, it wants to pick the smallest file that will still look sharp. To do that it needs two numbers: how many CSS pixels wide the image will be rendered, and the device pixel ratio. Multiply them and you have the ideal file width in real pixels. A 400px-wide slot on a 2× display wants roughly an 800px file.

srcset tells it what widths exist. sizes tells it the rendered width. Without sizes, the browser has to guess, and the specification says it guesses 100vw.

That default is the whole problem. On a 1440px desktop viewport, a thumbnail rendered at 300px will trigger the download of a file sized for 1440px or more — roughly twenty times the necessary bytes for that slot. The markup is technically correct. The outcome is worse than a plain src pointing at a sensibly sized file.

The syntax, minimally

<img
  src="/img/workshop-800.webp"
  srcset="/img/workshop-400.webp 400w,
          /img/workshop-800.webp 800w,
          /img/workshop-1200.webp 1200w,
          /img/workshop-1800.webp 1800w"
  sizes="(max-width: 640px) 100vw,
         (max-width: 1024px) 50vw,
         33vw"
  width="1200" height="675"
  alt="A key cutting machine on a workshop bench"
  loading="lazy" decoding="async">

Reading it in order: there are four files, described by their real pixel widths with the w descriptor. Below 640px the image fills the viewport; between 640 and 1024 it takes half; above that, a third. The src is the fallback for anything that does not understand srcset, which in practice means nothing modern but costs nothing to include.

width and height are not optional. They give the browser the aspect ratio before the file arrives so it can reserve the space, which is what prevents the layout shift that would otherwise show up in Cumulative Layout Shift. Set them to the intrinsic dimensions of any one variant; the ratio is what matters, not the values.

Writing the sizes value correctly

sizes is a list of media conditions with a width, evaluated left to right, first match wins, with a final unconditional fallback. It describes the rendered width, not the file.

The way to get it right is to open the page, resize the window to each breakpoint, and measure the image in devtools. Then write down what you measured. That sounds laborious and it takes about two minutes per template — and templates are the unit here, not images. A site with six templates has six sizes values, not six thousand.

Three patterns cover nearly everything:

Full-bleed hero. sizes="100vw" — genuinely full width at every breakpoint. This is the only case where the default is correct.

Constrained content column. sizes="(max-width: 800px) 100vw, 760px" — full width on mobile, then a fixed pixel width once the column stops growing. Note the absolute value; sizes accepts px and using it for a fixed container is more accurate than approximating with a viewport percentage.

Grid item. sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" — the columns-per-row pattern above.

Two errors are worth naming because both are common. Overstating the width wastes bytes silently and is the failure this whole article is about. Understating it produces a visibly soft image on high-density screens, which someone will eventually report as "the photos look blurry on my laptop" without connecting it to a markup change made months earlier. If in doubt, err slightly high — a modest overshoot costs bytes, an undershoot costs quality, and quality complaints are more expensive to diagnose.

Also remember that sizes must be updated when the layout changes. A redesign that narrows the content column leaves every sizes value overstating by exactly the amount the column shrank, and nothing will flag it.

Choosing which widths to generate

Do not generate a variant every 100px. The marginal saving from a denser ladder is small and the storage, build time and cache fragmentation are not.

Work backwards from the largest slot. If the image is never rendered wider than 800 CSS pixels, the largest variant you need is around 1600px for 2× displays — anything above that is dead weight nobody will ever select. Then step down by roughly 1.4× to 1.5× per rung, which keeps the browser within a few percent of ideal at any width. Four rungs from 400 to 1800 covers a full-width hero comfortably; three from 300 to 900 covers a grid thumbnail.

The single most valuable check here is the ceiling. Sites routinely ship a 3000px variant because the original upload was 3000px, and on a slot that renders at 600px that file exists solely to be downloaded by someone on a 4K monitor with a fast connection who did not need it either.

When to reach for picture instead

srcset and sizes do resolution switching: same image, different sizes. The <picture> element does something different — it lets you swap the image itself.

Two legitimate uses:

Art direction. A wide landscape hero that works on desktop becomes an unreadable sliver on mobile, so you serve a tighter crop below a breakpoint. This is a genuinely different image, not a smaller one, and srcset cannot express it.

<picture>
  <source media="(max-width: 640px)" srcset="/img/hero-square-800.webp">
  <img src="/img/hero-wide-1600.webp" width="1600" height="900" alt="…">
</picture>

Format negotiation. Offer AVIF, then WebP, then a JPEG fallback, and let the browser take the first it supports. Whether that ladder is worth maintaining depends on your traffic and your encoder, and the practical trade-off between WebP, AVIF and JPEG is a decision to make once rather than per image — for most sites WebP alone with no fallback is now the defensible default.

What you should not do is use <picture> for plain resolution switching. It works, but it is more markup expressing less intent, and you lose the browser's ability to factor in network conditions.

What your framework or CMS is doing on your behalf

Almost nobody writes this markup by hand any more, which means the practical question is what your platform generates and where it needs correcting.

Next.js. The next/image component generates the srcset from your configured device and image sizes and requires you to pass sizes explicitly for any image using fill or a responsive layout. Omit it and you get the 100vw default plus a build-time warning that is very easy to scroll past. The correction is a one-line prop per component, and it is the single highest-value change available in most Next.js codebases.

WordPress. Core has generated srcset since version 4.4 and does it correctly, deriving the list from the registered image sizes. The sizes attribute is the weak point: the default assumes an image close to full content width, which is right for a post hero and wrong for every thumbnail, archive grid and sidebar image on the site. It is filterable, and filtering it per template is the fix. Registering sensible image sizes in the first place matters too, since the theme's registered sizes are the only widths that will ever exist. How WordPress handles images and which plugin behaviour to watch covers the surrounding pipeline.

Hosted platforms. Squarespace, Wix and Webflow all generate responsive markup automatically and expose very little of it. The good news is that their defaults are reasonable; the bad news is that when they are wrong you generally cannot fix them. What is and is not controllable on those platforms is worth knowing before you spend an afternoon trying.

In every case the verification is the same: read the rendered HTML. A platform that claims responsive images and emits a sizes value of 100vw on a grid thumbnail has claimed accurately and helped you not at all.

How this interacts with your LCP image

There is one important exception to everything above, and it applies to exactly one image per page.

Your Largest Contentful Paint element — usually the hero — should not be lazy-loaded, and it benefits from fetchpriority="high". Lazy-loading it delays discovery past layout, which is the most common self-inflicted LCP problem on otherwise fast sites. Every other image below the fold should carry loading="lazy".

The sizes accuracy point matters most on this image too, because it is the one whose download time you are measuring. Diagnosing and fixing an image-driven LCP end to end covers the discovery and preload side of that in more depth; this article is the byte-selection half of the same problem.

Verifying it works

Do not trust the markup. Check the outcome, in this order:

  1. Open devtools, Network panel, filter to images, and add the "Size" column. Load the page at a mobile viewport with device emulation and 2× DPR. Note which variant was fetched for each image.
  2. Compare against the rendered width. Hover the element and read its layout size. Fetched width should be roughly rendered width × DPR. More than about 1.5× over is a sizes error.
  3. Repeat at your desktop breakpoint. The wrong sizes value often looks fine at one breakpoint and badly wrong at another, which is how these survive review.
  4. Check Lighthouse's "Properly size images" audit, which reports the estimated waste per image and is a fast way to rank what to fix.
  5. Check a page you did not build recently. Templates drift, and the sizes value written for the old layout is still there.

If you are running this across a whole site rather than one template, oversized delivery is one of the six defects a systematic image audit is designed to surface, and the crawler export sorted by file size will point you at the templates worth opening first.

The short version

Generate three to five variants per image, capped at roughly twice the largest rendered width. Write a sizes value per template by measuring the layout rather than guessing. Always set width and height. Lazy-load everything except the LCP image, and give that one fetchpriority="high".

That is the whole discipline, and it is a template-level job — six values written once, not a per-image chore. If you want it as part of a broader pass, it sits alongside filenames, alt text and metadata on the standing image SEO checklist, where it is comfortably the item with the largest measurable payoff.

Frequently asked questions

What is the difference between srcset and sizes?+

srcset lists the image files you have and how wide each one is; sizes tells the browser how wide the image will be displayed at a given viewport width. The browser needs both to choose correctly — srcset alone leaves it guessing the display width, and its default guess is the full viewport width, which is wrong for any image that is not full-bleed.

What happens if I omit the sizes attribute?+

The browser assumes 100vw — that the image occupies the full viewport width. For a sidebar thumbnail or a three-column grid item that assumption is wildly wrong, and the browser dutifully downloads a file several times larger than needed. Omitting sizes is the single most common reason a correctly built srcset saves nothing.

How many widths should I put in srcset?+

Three to five is almost always enough. The gains from additional entries fall off quickly while the cost of generating and storing variants does not, and a browser picking a file 15 percent larger than ideal is a rounding error. Pick widths that cover your real layout breakpoints plus a high-density option for the largest slot.

When should I use the picture element instead of srcset?+

Use picture when the image itself should change — a different crop on mobile, or a different format like AVIF with a JPEG fallback. Use srcset with sizes when it is the same image at different resolutions. Reaching for picture to do plain resolution switching works but is more markup for no benefit.

Do w descriptors and x descriptors do the same thing?+

No. The x descriptor selects by pixel density only and ignores layout width, which suits fixed-size images like logos and avatars. The w descriptor lets the browser combine density with your sizes hint to pick by actual rendered width, which is what you want for anything that scales with the layout.

Does my CMS generate srcset correctly?+

Most modern CMSes and frameworks generate the srcset list correctly and the sizes attribute badly, because they cannot know your layout. WordPress defaults to a sizes value that assumes a near-full-width image; Next.js requires you to pass sizes explicitly and warns when you do not. Check the rendered HTML rather than trusting the feature.

Can responsive images hurt performance?+

Yes, in two ways. Browsers may pick a larger file than needed if sizes overstates the display width, which is the common failure. And a very long srcset on a page with many images adds parsing and decision overhead for negligible benefit — three to five entries avoids both problems.

Let SEOpix handle the metadata

Filenames, alt text, EXIF fields and GPS coordinates written automatically as each image is generated. Start with 10 free images a month — no credit card required.

Keep reading