The sizes Attribute Is a Promise About Your CSS

You did the image pipeline right. Every photo is resized at build time into five widths, encoded as AVIF and WebP, and wired into a srcset. Then you open the network panel on a phone-sized viewport and watch a 380-pixel-wide card thumbnail download the 1600w variant.
The pipeline is fine. The problem is one attribute you probably wrote once, during the first layout, and never touched again: sizes.
sizes is not a hint about your images. It is a promise about your CSS. And CSS changes far more often than anyone updates the promise.
Why the browser needs you to tell it
When the HTML parser hits an <img>, the browser wants to start the download immediately. The preload scanner runs ahead of layout, often before your stylesheet has even arrived. At that moment the browser knows the viewport width and the device pixel ratio. It does not know how wide your image will render, because that depends on CSS it has not applied yet.
So with width descriptors, the browser does arithmetic on what you gave it:
- Evaluate
sizesto get a slot width in CSS pixels. - Multiply by the device pixel ratio.
- Pick a
srcsetcandidate that covers that many physical pixels.
<img
src="/media/harbor-800w.webp"
srcset="
/media/harbor-400w.webp 400w,
/media/harbor-800w.webp 800w,
/media/harbor-1200w.webp 1200w,
/media/harbor-1600w.webp 1600w
"
sizes="(min-width: 1024px) 380px, (min-width: 640px) 50vw, 100vw"
alt="Fishing boats moored in a harbor at dusk"
width="1600"
height="1067"
/>
On a 1440px laptop at 2x density, that evaluates to 380px, needs 760 physical pixels, and picks 800w. Leave sizes off and the default is 100vw: 1440px times 2 is 2880, so the browser grabs the largest file you offered. That is roughly 1600 by 1067 versus 800 by 533, four times the pixels for an image that displays at the same size either way.
The browser is not being dumb. It is being obedient.
How it drifts
Nobody writes a wrong sizes on purpose. It goes wrong later, in commits that never mention images.
- The grid changes. A two-column card layout becomes three columns. Every card is now a third narrower, and
sizesstill says50vw. - The container changes. The site’s max width goes from 1200px to 1440px, or a sidebar appears. The fixed pixel value in
sizesno longer matches anything. - The component moves. A card component written for the homepage grid gets reused in a narrow “related posts” rail. Same component, same hardcoded
sizes, half the rendered width. - The breakpoints move. Your design system shifts its tablet breakpoint from 768px to 640px. The media conditions in
sizesare a second copy of those breakpoints that nothing keeps in sync.
Each of these is a CSS change reviewed by someone looking at CSS. Nothing in the diff, the type checker, or the build output says the image markup just became a lie.
And the failure is asymmetric in an annoying way. A sizes value that is too large wastes bytes silently. A sizes value that is too small makes images blurry, which someone eventually notices. So drift almost always settles on the wasteful side, because that is the side nobody complains about.
The syntax traps
Even fresh sizes values are often wrong because the rules are less forgiving than they look.
First match wins. The browser walks the list left to right and uses the first media condition that matches. The final entry, with no condition, is the fallback. That means ordering matters:
<!-- Wrong: (min-width: 640px) also matches at 1440px, so 1024px is never reached -->
<img sizes="(min-width: 640px) 50vw, (min-width: 1024px) 380px, 100vw" />
<!-- Right: most specific min-width first -->
<img sizes="(min-width: 1024px) 380px, (min-width: 640px) 50vw, 100vw" />
No percentages. sizes is resolved without layout, so 50% has nothing to be a percentage of. Use vw, px, em, or calc().
calc() is your friend for padded layouts. A full-bleed image inside a container with 1rem gutters is not 100vw, it is narrower. On a 375px phone at 3x density the difference is small, but on wider images with real padding it can push you over a candidate boundary.
<img sizes="(min-width: 768px) 720px, calc(100vw - 2rem)" />
em in media conditions means the browser default. Media queries resolve em against the initial font size, not your root font-size override. If your CSS breakpoints are in em and your sizes breakpoints are in px, they will not line up for users who changed their default font size.
Fixed-size images do not need sizes at all. Avatars, logos, and icons render at the same CSS width everywhere. Use density descriptors instead and skip the guesswork:
<img
src="/media/avatar-64.webp"
srcset="/media/avatar-64.webp 1x, /media/avatar-128.webp 2x"
width="64"
height="64"
alt="Kevin"
/>
sizes=“auto” helps, but only for lazy images
The spec now allows sizes="auto" on images that also have loading="lazy". Lazy images do not load until layout has happened anyway, so the browser can measure the real rendered width and use that. Chromium-based browsers ship it. Check current support for the others before relying on it, and write a real fallback after it:
<img
loading="lazy"
sizes="auto, (min-width: 1024px) 380px, 100vw"
srcset="..."
/>
This is a good default for everything below the fold, and it removes most of the drift problem for those images.
It does nothing for the images that matter most. Your LCP image should not be lazy, so it still needs a hand-written sizes. Same for anything you preload with imagesrcset and imagesizes, where a mismatch means downloading two variants instead of one.
Frameworks do not save you from this
Image components automate the boring part, generating widths and formats. They cannot know your layout either.
- Next.js
next/imagewithfilland nosizesassumes100vw. Withoutsizeson a normal responsive image, it generates a small density-basedsrcsetinstead of the full width set. Either way, the component is only as correct as thesizesyou pass in. - Astro lets you pass
widthsandsizesto<Image>and<Picture>fromastro:assets. Its responsivelayoutoption can generatesizesfor you from the image’s declared width. That generated value is correct exactly when the image actually renders at that width, which is the same promise in a different place.
<Image
src={harbor}
alt="Fishing boats moored in a harbor at dusk"
widths={[400, 800, 1200, 1600]}
sizes="(min-width: 1024px) 380px, (min-width: 640px) 50vw, 100vw"
/>
The fix that actually scales is to keep sizes next to the layout that determines it. If a Card component knows it lives in a three-column grid above 1024px, the sizes string belongs in that component, not in a page template three files away. Better still, let the grid pass its own sizes down, so the component in the narrow rail gets a different value than the one in the main grid.
Test the promise in the build
Because the bug only shows up in a real browser at real viewport widths, check it in one. A short Playwright script against your built dist output catches drift before it ships:
// scripts/check-sizes.mjs
import { chromium } from 'playwright';
const pages = ['/', '/posts/', '/posts/some-long-post/'];
const viewports = [375, 768, 1280, 1920];
const base = 'http://localhost:4321';
function chosenWidth() {
return [...document.querySelectorAll('img')]
.filter((img) => img.currentSrc)
.map((img) => {
const sets = [img.srcset, ...[...(img.closest('picture')?.querySelectorAll('source') ?? [])].map((s) => s.srcset)];
const current = new URL(img.currentSrc, location.href).href;
let picked = null;
for (const set of sets) {
for (const candidate of set.split(',')) {
const [url, descriptor] = candidate.trim().split(/\s+/);
if (url && descriptor?.endsWith('w') && new URL(url, location.href).href === current) {
picked = parseInt(descriptor, 10);
}
}
}
const needed = Math.round(img.getBoundingClientRect().width * devicePixelRatio);
return { src: img.currentSrc, picked, needed };
})
.filter((r) => r.picked && r.needed > 0);
}
const browser = await chromium.launch();
let failures = 0;
for (const width of viewports) {
// Fresh context per viewport so cached larger candidates do not skew the result
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: 2 });
const page = await context.newPage();
for (const path of pages) {
await page.goto(base + path, { waitUntil: 'networkidle' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState('networkidle');
for (const r of await page.evaluate(chosenWidth)) {
const ratio = r.picked / r.needed;
if (ratio > 1.6 || ratio < 0.75) {
failures++;
console.log(`${width}px ${path} needed ${r.needed}w, got ${r.picked}w ${r.src}`);
}
}
}
await context.close();
}
await browser.close();
process.exit(failures ? 1 : 0);
A few notes on why it is shaped this way:
- It compares the picked candidate to the rendered width times DPR. That is the number
sizeswas supposed to predict. Some overshoot is normal, because candidates are discrete steps, so the threshold is a ratio rather than an exact match. - It scrolls first. Lazy images report an empty
currentSrcuntil they load. - It uses a fresh context per viewport. Browsers are allowed to reuse a larger cached candidate instead of fetching a smaller one, which would hide a bad
sizeson the second pass.
Run it against astro preview or any static server in CI. When someone changes the grid, the check fails and names the exact image and viewport. That turns an invisible bandwidth tax into a red build, which is the only kind of bug that reliably gets fixed.
The takeaway
Generating five image widths is the easy half of responsive images. The hard half is telling the browser, before it has your CSS, which one it needs. That is all sizes does, and it is only ever as accurate as the last time someone checked it against the layout.
Use sizes="auto" for lazy images, density descriptors for fixed-size ones, and keep every remaining sizes string inside the component that owns the layout. Then verify it in a real browser on every build. A promise nobody checks is just a guess with good intentions.

