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.
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);
Related
- Mastering srcset and sizes for Responsive Layouts — full attribute syntax, candidate selection algorithm, and
<picture>patterns - Art Direction with the HTML picture Element — per-breakpoint crop and format switching beyond what
sizesalone can express - Using fetchpriority to Optimise Critical Media — LCP prioritisation and the starvation risk when
fetchpriority="high"is overused - Cache-Control Headers for Image and Video Assets — immutable caching strategy for the new variant URLs that correct
sizesvalues introduce