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.
-->
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.
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.
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);
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 - CSS Container Queries for Dynamic Media Sizing — what to do when the image’s slot depends on its container rather than the viewport