The browser picks a srcset candidate by dividing each width descriptor by the slot width from sizes, then taking a candidate whose density covers the screen's device pixel ratio. Leave sizes out and the slot is the full viewport width. On a 1280 px laptop with a 720 px column, that one omission took the download from 58.4 KB to 170.3 KB in my test.
What does the browser compute?
Three numbers per image:
-
The slot width. The browser walks the
sizeslist and takes the first media condition that matches. No match falls to the last entry. Nosizesat all means100vw, which MDN's img reference states as the default. -
Each candidate's density. A
1600wcandidate in a 720 px slot has a density of 1600 / 720 = 2.22. - The density it needs. That is the device pixel ratio: 1 on most laptops, 2 or 3 on phones.
The browser then picks a candidate with enough density. The spec leaves the final choice to the browser. MDN says the user agent selects any of the available sources at its discretion, so a browser may pick a smaller file on a slow connection or reuse a larger one it has cached. The rule below is the common case, not a promise.
Can you reproduce it in code?
Yes. Here is the pick for one sizes value, plus the real bytes of each candidate, made with sharp from a 1920 × 1280 JPEG:
import sharp from 'sharp';
const src = 'jpg-sample-photo-original.jpg';
const widths = [480, 800, 1200, 1600, 1920];
const bytes = {};
for (const w of widths) bytes[w] = (await sharp(src).resize(w).webp({ quality: 75 }).toBuffer()).length;
console.log('candidate bytes', bytes);
// sizes="(min-width: 1024px) 720px, (min-width: 640px) 50vw, 100vw"
const slot = (vw) => (vw >= 1024 ? 720 : vw >= 640 ? vw * 0.5 : vw);
// Pick the smallest candidate whose density (w / slot) covers the device pixel ratio.
const pick = (slotPx, dpr) => widths.find((w) => w / slotPx >= dpr) ?? widths.at(-1);
for (const [vw, dpr] of [[375, 2], [375, 3], [414, 3], [768, 2], [1280, 1], [1440, 2], [1920, 1]]) {
const s = slot(vw), p = pick(s, dpr), p100 = pick(vw, dpr);
console.log(vw, dpr, s, s * dpr, p, bytes[p], '|', p100, bytes[p100]);
}
The output:
candidate bytes { '480': 22818, '800': 58440, '1200': 112234, '1600': 170268, '1920': 218908 }
viewport dpr slot needed picked bytes | no sizes (100vw): picked bytes
375 2 375 750 800 58440 | 800 58440
375 3 375 1125 1200 112234 | 1200 112234
414 3 414 1242 1600 170268 | 1600 170268
768 2 384 768 800 58440 | 1600 170268
1280 1 720 720 800 58440 | 1600 170268
1440 2 720 1440 1600 170268 | 1920 218908
1920 1 720 720 800 58440 | 1920 218908
Where does a missing sizes cost you?
On phones, nowhere. The image fills the screen, so 100vw is the truth, and both columns match.
On anything wider, the image sits in a column, and 100vw overstates the slot. These are the rows that changed:
| Viewport | DPR | With sizes
|
Without | Extra bytes |
|---|---|---|---|---|
| 768 px | 2 | 58.4 KB | 170.3 KB | 111.8 KB |
| 1280 px | 1 | 58.4 KB | 170.3 KB | 111.8 KB |
| 1440 px | 2 | 170.3 KB | 218.9 KB | 48.6 KB |
| 1920 px | 1 | 58.4 KB | 218.9 KB | 160.5 KB |
The 1920 px desktop is the worst. Its slot is 720 px, but without sizes the browser thinks it is 1920 px and fetches the largest file: 3.7 times the bytes it needed.
Note:
sizesdescribes the layout, not the file. It must match your CSS at each breakpoint. If your column is 720 px butsizessays50vw, a 1920 px screen computes a 960 px slot and fetches1200w. Nothing errors. You just pay for it.
Why did the 414 px phone fetch 1600w?
A 414 px phone at 3x needs 1242 device pixels. The 1200w candidate falls 42 short, so the next one up wins: 1600w, 170.3 KB. That is a gap in the ladder, not in sizes.
The fix is a candidate near the common needs. The three phones in the table need 750, 1125 and 1242 device pixels. A 1280w step would catch the 414 px phone with 38 pixels to spare. Check the needs from your own analytics, not a stock list.
How do you write sizes for a real layout?
Read the slot from your CSS at each breakpoint, widest first:
<img
src="photo-800.webp"
srcset="photo-480.webp 480w, photo-800.webp 800w, photo-1200.webp 1200w,
photo-1600.webp 1600w, photo-1920.webp 1920w"
sizes="(min-width: 1024px) 720px, (min-width: 640px) 50vw, 100vw"
width="1920" height="1280"
alt="An iguana on a branch">
Three habits help:
-
Use
pxfor fixed columns andvwfor fluid ones.calc()works too:calc(100vw - 32px)for a full-bleed image with 16 px gutters. - Order matters. The browser takes the first match, so put the widest condition first.
-
Keep
widthandheight. They reserve the box before the file arrives;sizesdoes not.
sizes="auto" is newer. It lets the browser use the laid-out width of a lazy-loaded image. MDN lists auto as a valid value; check support on caniuse before you rely on it, and keep an explicit fallback after it.
How do you check what was picked?
In Chrome DevTools, select the <img> and read currentSrc in the console:
$0.currentSrc
Resize the window and reload; a browser that already holds a larger candidate may keep it. To see the bytes on a live page, the page image analyzer lists each image's weight.
How I measured
- Apple Silicon Mac (M1 Pro), 2026-10-17, Node 24.22.0, sharp 0.35.4 with libvips 8.18.6.
- Source:
jpg-sample-photo-original.jpgfrom the Blinkimg site, 1920 × 1280, 576,828 bytes. Candidates are WebP q75 at five widths. - The pick function is my model of the common rule, not browser code.
What I did not test
I did not record which candidate Chrome, Safari or Firefox fetched at each size. Cache reuse and data-saver settings can change the pick.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)