How to Calculate Optimal sizes Attribute Values

When sizes defaults to 100vw, the browser’s preloader requests the widest available source regardless of your CSS layout — inflating LCP, wasting bandwidth, and thrashing CDN caches. Deriving precise viewport-relative widths from your actual CSS geometry is the fix. This guide is a companion to Mastering srcset and sizes for Responsive Layouts, which covers the full attribute syntax, candidate selection algorithm, and multi-format <picture> patterns.

Prerequisite Checklist

Before calculating sizes values, confirm the following are in place:

The Core Problem: Why 100vw Is Almost Never Correct

The browser’s speculative preloader fires before CSS layout is computed. Without a sizes attribute it assumes the image occupies the full viewport width and fetches the widest candidate from srcset. On a 1440 px desktop where the image renders at 640 px inside a centred two-column grid, the browser may download a 2000 px asset — 3× larger than necessary.

The formula that connects CSS geometry to sizes is:

effective_vw = (rendered_container_px / viewport_px) × 100

Subtract fixed gutters and scrollbar offsets using calc():

effective_vw = calc(<percentage>vw - <margin_px>px)

The diagram below illustrates how three common layout patterns map to their correct sizes values.

Responsive layout patterns and sizes values Three panels: full-bleed at mobile (calc 100vw minus 32px), two-column at tablet (calc 50vw minus 24px), and fixed-width column at desktop (640px). image 148 px / 180 px vp calc(100vw - 32px) 16 px gutter × 2 Mobile ≤ 600 px image 98 px / 230 px vp calc(50vw - 24px) gap 24 px + 12 px pad Tablet 601–1024 px image 640 px fixed col 640px fallback — no condition Desktop > 1024 px

Step-by-Step Calculation

Step 1 — Measure rendered container width at every breakpoint

Open DevTools → Elements panel → select the <img> element → Computed tab. Note the value shown for width (this is the CSS layout width, not the intrinsic pixel dimension of the source file). Repeat at each breakpoint by resizing the DevTools viewport or using the device-toolbar presets.

Alternatively, run this in the console:

// Read rendered widths at the current viewport size.
// Run at each breakpoint after resizing the window.
document.querySelectorAll('img[srcset]').forEach(img => {
  const rect = img.getBoundingClientRect();
  console.log(
    img.getAttribute('alt') || img.src,
    `rendered: ${Math.round(rect.width)}px`,
    `viewport: ${window.innerWidth}px`,
    `ratio: ${(rect.width / window.innerWidth * 100).toFixed(1)}vw`
  );
});

Step 2 — Convert pixels to viewport-relative units

Apply the formula and note the fixed offsets (scrollbar width, horizontal padding, gap):

Breakpoint Viewport (px) Container (px) Raw vw Fixed offset sizes value
≤ 600 px 375 343 91.5 vw 32 px gutters calc(100vw - 32px)
601–1024 px 768 360 46.9 vw 48 px gap+pad calc(50vw - 24px)
> 1024 px 1440 640 44.4 vw fixed column 640px

Use calc() whenever a fixed pixel amount is subtracted — mixing vw with px requires it. Avoid em-based offsets unless the image container is explicitly sized in em, because em is relative to the element’s font size, which is ambiguous in the preloader context.

Step 3 — Write the sizes string with conditions in ascending order

The browser evaluates media conditions left to right and selects the first match. Write the narrowest breakpoint first; the last entry is the unconditional fallback:

<!--
  sizes conditions are evaluated left-to-right; the browser stops at the first match.
  The final value (640px) has no media condition and acts as the fallback for wide viewports.
  calc() is required when mixing vw (relative) and px (absolute) units.
-->
<img
  srcset="
    hero-480w.jpg   480w,
    hero-800w.jpg   800w,
    hero-1200w.jpg 1200w,
    hero-2000w.jpg 2000w
  "
  sizes="
    (max-width: 600px)  calc(100vw - 32px),
    (max-width: 1024px) calc(50vw - 24px),
    640px
  "
  src="hero-800w.jpg"
  alt="Hero banner showing a responsive media pipeline"
  loading="eager"
  fetchpriority="high"
/>
<!--
  Warning: fetchpriority="high" is only appropriate for the single LCP image.
  Setting it on multiple images forces the browser to schedule all of them at high
  priority simultaneously, starving CSS and fonts — see the fetchpriority guide for details.
-->

Step 4 — Validate with ResizeObserver and Lighthouse

Confirm the browser selected the expected candidate by comparing the rendered width to img.currentSrc:

// Validation: log rendered width vs actual downloaded source.
// Attach once at page load; disconnect after 5 s to avoid continuous layout overhead.
const img = document.querySelector('img[fetchpriority="high"]');

if (img) {
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const rendered = Math.round(entry.contentRect.width);
      const dpr = window.devicePixelRatio || 1;
      const expectedSrcWidth = rendered * dpr;

      console.log(
        `[sizes audit] rendered: ${rendered}px | DPR: ${dpr}` +
        ` | expected src: ~${Math.round(expectedSrcWidth)}px`
      );
      console.log(`[sizes audit] actual src: ${img.currentSrc}`);

      // Extract the integer width from the src filename (e.g. "hero-800w.jpg" → 800).
      const match = img.currentSrc.match(/(\d+)w\./);
      if (match) {
        const srcWidth = parseInt(match[1], 10);
        const overFetch = ((srcWidth - expectedSrcWidth) / expectedSrcWidth * 100).toFixed(0);
        if (srcWidth > expectedSrcWidth * 1.2) {
          console.warn(`[sizes audit] Over-fetching by ~${overFetch}% — check sizes declaration.`);
        }
      }
    }
  });

  observer.observe(img);
  setTimeout(() => observer.disconnect(), 5000);
}

Then run a Lighthouse audit to confirm the “Properly sized images” rule passes:

npx lighthouse https://your-domain.com \
  --output=json \
  --only-categories=performance \
  --chrome-flags='--headless' \
  | node -e "
    const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
    // 'largest-contentful-paint' displayValue includes the unit, e.g. '1.2 s'
    console.log('LCP:', d.audits['largest-contentful-paint'].displayValue);
    // 'uses-responsive-images' is null if all images are correctly sized
    const audit = d.audits['uses-responsive-images'];
    console.log('Oversized images:', audit?.details?.items?.length ?? 0);
  "

Expected Metric Deltas

Metric Expected delta Notes
LCP −0.4 s to −0.9 s Reduced TTFB from smaller asset; improvement is larger at mobile 4G
Bandwidth per page view −35% to −60% Eliminates unnecessary 2×/3× downloads at desktop DPR
CLS Neutral or positive No layout shift from oversized image scaling down
CDN cache hit ratio +10%–15% Smaller, correctly sized variants cluster into fewer cache slots

Common Mistakes and Fixes

Anti-pattern 1: Omitting sizes entirely when srcset uses w descriptors.

Without sizes, the specification requires the browser to behave as if sizes="100vw" is set. For a card grid where each image is 25 vw, this causes the browser to fetch an asset four times wider than necessary.

Fix: always pair w-descriptor srcset with an explicit sizes attribute.


Anti-pattern 2: Writing media conditions in descending order (largest first).

<!-- Wrong: browser matches the first condition; wide screens always win -->
sizes="(min-width: 1024px) 640px,
       (min-width: 601px)  calc(50vw - 24px),
       calc(100vw - 32px)"

Because the browser stops at the first match, a 375 px viewport also matches (min-width: 0px) implicitly — but using min-width requires reversing the conventional order. Stick to max-width conditions in ascending order unless you specifically need min-width semantics and account for the reversed evaluation order.

Fix: use max-width conditions in ascending order, with the narrowest breakpoint first.


Anti-pattern 3: Using intrinsic image dimensions instead of rendered CSS dimensions.

Developers sometimes read the naturalWidth of the <img> element and use that as the basis for sizes. This measures the source file, not the layout geometry.

Fix: always measure via getBoundingClientRect().width or the Computed tab — not naturalWidth.


Anti-pattern 4: Forgetting fixed offsets on full-bleed mobile layouts.

A layout with 16 px padding on each side means 100vw over-fetches by 32 px. At 375 px that is an ~8.5% error, which can push the browser to select the next larger srcset candidate.

Fix: use calc(100vw - 32px) (or the actual combined offset) rather than a bare 100vw.


Anti-pattern 5: Invalidating sizes with JS-driven layout changes post-load.

Single-page apps that expand sidebars or open drawers change the image’s rendered width after the initial preload has already fired. The sizes value is now stale and the wrong asset was downloaded.

Fix: when layout width changes programmatically, update img.sizes with a ResizeObserver callback. Note that reassigning img.sizes only triggers a new network request if it changes which srcset candidate the browser would select — it will not re-download the same URL.

// Dynamically update sizes when a sidebar is toggled.
const sidebar = document.getElementById('sidebar');
const heroImg = document.getElementById('hero');

const ro = new ResizeObserver(() => {
  const vw = window.innerWidth;
  const rendered = heroImg.getBoundingClientRect().width;
  // Recalculate and update sizes so next intersection uses correct candidate.
  heroImg.sizes = `${Math.round(rendered / vw * 100)}vw`;
});

ro.observe(heroImg);