Medium wave · 540–1600 kHz77 stn
1567kHz

The sizes Attribute Is a Promise About Your CSS

A row of brass picture frames of graduated sizes mounted on a dark steampunk wall, an oversized glowing canvas being forced by clockwork arms into the smallest frame, its edges spilling neon teal light over the brass, while precise copper measuring calipers hang unused beside it in drifting fog

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:

  1. Evaluate sizes to get a slot width in CSS pixels.
  2. Multiply by the device pixel ratio.
  3. Pick a srcset candidate 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 sizes still says 50vw.
  • The container changes. The site’s max width goes from 1200px to 1440px, or a sidebar appears. The fixed pixel value in sizes no 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 sizes are 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/image with fill and no sizes assumes 100vw. Without sizes on a normal responsive image, it generates a small density-based srcset instead of the full width set. Either way, the component is only as correct as the sizes you pass in.
  • Astro lets you pass widths and sizes to <Image> and <Picture> from astro:assets. Its responsive layout option can generate sizes for 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 sizes was 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 currentSrc until 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 sizes on 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.