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.
-->

Trace that string against the viewport axis before you ship it. The threshold map below plots the three conditions end to end and works out, for one representative viewport in each band, which candidate the preloader ends up requesting from the four-entry srcset above.

Viewport threshold map for the sizes string A horizontal viewport-width axis from 320 to 1600 pixels divided into three bands at 600 and 1024 pixels. Each band shows its sizes value and media condition, and a worked example beneath it converting the slot width and device pixel ratio into the chosen srcset candidate. Threshold map for the sizes string above conditions are evaluated left to right — the first match wins calc(100vw - 32px) calc(50vw - 24px) 640px (max-width: 600px) (max-width: 1024px) no condition — fallback 320 px 600 px 1024 px 1600 px 375 px viewport · DPR 2 slot = 375 − 32 = 343 px 343 × 2 = 686 → hero-800w 768 px viewport · DPR 2 slot = 384 − 24 = 360 px 360 × 2 = 720 → hero-800w 1440 px viewport · DPR 1 slot = 640 px fixed column 640 × 1 = 640 → hero-800w The 2000w candidate is only reached at DPR 2 above 1024 px (640 × 2 = 1280 px required)

Three of the four candidates in that srcset are therefore doing real work, and hero-2000w.jpg exists solely for high-density desktop displays. If your analytics show almost no DPR-2 traffic above 1024 px, drop the 2000w variant entirely — every unused candidate is build time, storage, and one more CDN cache slot to keep warm.

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

Plotted against each other, the payload change and the LCP change come from the same single edit — the browser asks for a smaller candidate, so the bytes arrive sooner. Nothing else in the page changes.

Payload and LCP before and after calibrating sizes Two horizontal bars compare the baseline payload with an omitted sizes attribute against the calibrated payload, which is 35 to 60 percent smaller, followed by a relative LCP axis showing the calibrated variant painting 0.4 to 0.9 seconds earlier. One edit, two metrics: payload and LCP sizes omitted (100vw) downloads hero-2000w.jpg baseline bytes calibrated sizes downloads hero-800w.jpg −35% to −60% bytes LCP (relative) baseline LCP calibrated LCP 0.4 s – 0.9 s earlier time → CDN hit ratio also rises 10–15% as requests converge on fewer variant widths

The cache-ratio gain is the least obvious of the three. Every distinct sizes value your pages emit multiplies the number of variant URLs an edge node has to keep resident; collapsing a hand-written set of near-identical widths into one shared scale — the same discipline a build-time pipeline such as Vite imagetools srcset generation enforces automatically — means more requests land on already-cached objects.

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.

This is also where component-scoped layout bites: if the card is sized by CSS container queries rather than by the viewport, the same markup can render at 300 px in a sidebar and 900 px in a main column on one viewport width, and no single static sizes string is correct for both.

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);