Auditing srcset Candidate Selection with DevTools
Candidate selection is the only part of the image pipeline that produces no artefact you can read directly: the browser picks a URL from srcset, and nothing in the console, the HTML, or the rendered page says which one won or what arithmetic produced it. Worse, the answer changes with viewport width, device pixel ratio, cache contents, and whether a <picture> <source> pre-empted the <img> — so a single spot check proves almost nothing. This page is a fixed procedure that makes the decision observable and reproducible, and then generalises it into a sweep that finds candidates no real device can ever reach. It assumes the markup rules from Mastering srcset and sizes for Responsive Layouts and concerns itself only with proving what the browser did with them.
Prerequisite checklist
What DevTools will and will not tell you
Four questions matter, and they live in four different places. Getting the surfaces straight is most of the work.
| Question | Where the answer is | Trap |
|---|---|---|
| Which candidate won? | Elements → Properties → currentSrc |
The src attribute in the Elements tree is not the winner; it is the fallback. |
| What arrived on the wire? | Network → Request URL and Size columns | A cached candidate produces no row at all. |
| Did the preloader choose it, or did script? | Network → Initiator column | Parser means the preload scanner; anything else means selection happened after layout. |
| Which element supplied the source set? | currentSrc compared against each <source srcset> |
A <source> that matches replaces the <img> source set and its sizes entirely. |
The second Network row is the one people misread most often. An Initiator that names a script file means the element’s src or srcset was mutated after parsing — by a hydration pass, a lazy-load polyfill, or a carousel — and the candidate you are looking at was chosen against post-layout geometry rather than by the preload scanner. That is not automatically wrong, but it means the image cannot be an LCP candidate the scanner fetches early, which matters if you are also tuning fetchpriority on critical media.
The fourth row of the table deserves its own warning because it produces a diagnosis that is confidently wrong. When an <img> sits inside a <picture>, the browser walks the preceding <source> siblings and the first one whose type is decodable and whose media matches supplies both the candidate list and the sizes list; the <img> element’s own attributes are then never consulted. If you audit by reading img.srcset and img.sizes you will be checking a source set that had no influence on the outcome, and every conclusion drawn from it — the slot, the required pixels, the expected winner — will be off. The only reliable move is to compare currentSrc against each <source srcset> in document order and identify which list the winning URL actually came from. That is why the sweep script later on resolves the applied source set from the <picture> ancestor rather than trusting the image element.
Step 1 — pin the three inputs
Selection reads exactly three things you control from the browser: the viewport width, the device pixel ratio, and — indirectly — what is already in the cache. Fix all three or the result is not reproducible.
Viewport width. Open the device toolbar (Ctrl/Cmd + Shift + M), switch the dropdown to Responsive, and type an exact width. Dragging the handle is not good enough near a breakpoint; a two-pixel error flips which sizes condition matches.
Device pixel ratio. Responsive mode inherits the DPR of the machine you are sitting at, which on most desktops is 1 and on a Retina laptop is 2. Neither is what your mobile traffic looks like. Open the device toolbar’s three-dot menu, tick Add device pixel ratio, and a DPR field appears next to the dimensions — set it to 3 and confirm in the console that window.devicePixelRatio reports 3 before you trust anything downstream.
Cache state. Tick Disable cache in the Network panel and keep DevTools open, because the setting only applies while it is. For a genuinely clean run, use a fresh incognito window: Disable cache bypasses the HTTP cache but a same-session memory cache entry can still satisfy an image.
Warning: changing the DPR field does not re-run selection on images that have already loaded. Reload after every change. Chrome re-evaluates the source set on viewport resize, but a DPR override applied to a settled page frequently leaves currentSrc pointing at the previous winner.
Two second-order effects are worth knowing before you start collecting numbers. The first is that a re-evaluation triggered by resizing is not the same event as the initial preload-scanner pass: the scanner runs during tokenisation with no layout available and uses sizes alone, whereas a resize-triggered re-run happens with a full layout tree in memory. On a page whose sizes value is accurate the two agree, which is precisely why an inaccurate one is so easy to miss — drag the window and the image quietly corrects itself, hiding the fact that the first load fetched the wrong file. Always take the reading from a fresh load at a fixed size, never from a window you have just resized.
The second is scrollbar width. On Windows and Linux, a classic overlay-free scrollbar removes 15–17 px from the viewport that 100vw still counts, so a sizes value expressed in vw resolves slightly larger than the space the layout actually has. It is a rounding-scale error most of the time, but it is enough to flip the winner when a required-pixel total lands within a percent of a candidate boundary — which is exactly the situation a tight candidate ladder creates. If a profile reports a mismatch you cannot otherwise explain, check whether its required pixels sit within a few pixels of a rung.
Step 2 — the complete sweep: predicted versus actual
Clicking through six profiles by hand is fine once. To make it a regression test, run the matrix and — this is the part that turns an observation into a diagnosis — compute the expected winner independently from the source set and the rendered slot, then diff it against what the browser actually chose. A mismatch is a direct measurement of how far your sizes value is from reality, because the preloader used sizes and your prediction used the real layout.
// audit-srcset.mjs — node audit-srcset.mjs https://example.com
// npm i playwright && npx playwright install chromium
import { chromium } from 'playwright';
const url = process.argv[2];
// Each profile pins the two inputs the selection algorithm reads. Choose them
// from your analytics, not from a device catalogue: the pair that matters is
// (viewport CSS width, devicePixelRatio), and one phone model can report several.
const PROFILES = [
{ name: '360 × DPR 3', width: 360, height: 780, dpr: 3 },
{ name: '390 × DPR 3', width: 390, height: 844, dpr: 3 },
{ name: '768 × DPR 2', width: 768, height: 1024, dpr: 2 },
{ name: '1024 × DPR 2', width: 1024, height: 768, dpr: 2 },
{ name: '1440 × DPR 1', width: 1440, height: 900, dpr: 1 },
{ name: '1920 × DPR 2', width: 1920, height: 1080, dpr: 2 },
];
const browser = await chromium.launch();
const seen = new Map(); // candidate URL -> profiles that selected it
for (const p of PROFILES) {
const ctx = await browser.newContext({
viewport: { width: p.width, height: p.height },
deviceScaleFactor: p.dpr, // this is what window.devicePixelRatio returns,
// and therefore the number selection compares against
// A brand-new context has an empty HTTP cache AND an empty memory cache, so
// the "never downgrade to a smaller cached candidate" heuristic cannot fire.
});
const page = await ctx.newPage();
// networkidle, not load: lazily loaded images below the fold have not been
// selected yet at load, and their currentSrc would still be empty.
await page.goto(url, { waitUntil: 'networkidle' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState('networkidle'); // let lazy candidates resolve
const rows = await page.evaluate(() => {
const dpr = window.devicePixelRatio;
// Re-implement "select an image source" against the REAL rendered slot.
// The browser used the sizes attribute for this; we use getBoundingClientRect.
// Any disagreement between the two is the finding.
const predict = (srcset, slotPx) => {
const cands = srcset.split(',').map(s => s.trim()).filter(Boolean).map(entry => {
const [u, d] = entry.split(/\s+/);
if (!d) return { u, density: 1 }; // bare URL = 1x
if (d.endsWith('w')) return { u, density: parseFloat(d) / slotPx };
if (d.endsWith('x')) return { u, density: parseFloat(d) };
return null;
}).filter(Boolean).sort((a, b) => a.density - b.density);
// smallest candidate whose density reaches the DPR; else the largest
return (cands.find(c => c.density >= dpr) || cands[cands.length - 1] || {}).u;
};
return [...document.images]
.filter(img => img.currentSrc && img.srcset)
.map(img => {
const slot = img.getBoundingClientRect().width || 1;
// A <source> inside <picture> replaces the <img> source set entirely,
// so read the set that actually applied rather than img.srcset.
const src = img.closest('picture')?.querySelector('source[srcset]');
const applied = src ? src.srcset : img.srcset;
return {
el: img.className || img.alt?.slice(0, 24) || '(img)',
slot: Math.round(slot),
needed: Math.round(slot * dpr),
actual: img.currentSrc.split('/').pop(),
predicted: (predict(applied, slot) || '').split('/').pop(),
natural: img.naturalWidth,
};
});
});
console.log(`\n=== ${p.name} ===`);
console.table(rows.map(r => ({ ...r, agrees: r.actual === r.predicted ? 'yes' : 'NO' })));
for (const r of rows) seen.set(r.actual, [...(seen.get(r.actual) || []), p.name]);
await ctx.close();
}
// Any candidate present in the markup but absent from this map is dead weight:
// no profile in the matrix can ever select it. Delete it from the build.
console.log('\nCandidates selected by at least one profile:');
console.table([...seen].map(([file, profiles]) => ({ file, profiles: profiles.join(', ') })));
await browser.close();
The agrees column is the whole point. yes on every row means sizes describes the layout accurately at that profile. A NO means the preloader computed a different slot than the browser ultimately laid out — the sizes string is stale, or the image sits in a container whose width the viewport does not determine, which is the case CSS container queries for dynamic media sizing exists to handle.
The final table answers a different question: reachability. Plotting the same data as ranges makes the dead candidates obvious.
Verification
1. Confirm the request was parser-initiated
In the Network panel, set the filter to Img, add the Initiator column if it is hidden, and hover any row to see the initiator chain. For an above-the-fold image the value must be Parser. If it reads a script URL, the source set was assigned after parsing and the preload scanner never saw it — reproduce by disabling JavaScript (Ctrl/Cmd + Shift + P → Disable JavaScript) and reloading; if the image disappears, the markup is script-dependent.
2. Prove the DPR override actually applied
// Paste into the Console AFTER setting the DPR field and reloading.
// If these three disagree with what you typed, the override did not take.
console.log({
dpr: window.devicePixelRatio, // must equal the DPR field
viewport: window.innerWidth, // must equal the width field
matched: matchMedia('(min-resolution: 3dppx)').matches, // sanity check at DPR 3
});
3. Compare cold and warm runs deliberately
Load the page in incognito, note every currentSrc, then navigate away and back without clearing the cache. Chrome and Firefox will not downgrade to a smaller candidate when a larger one for the same element is already cached, so a page that looked correct on the second visit can be badly over-fetching on a first visit — and the Network panel shows no row at all for the reused file.
Common mistakes and fixes
1. Auditing at the host machine’s device pixel ratio
Responsive mode without an explicit DPR override runs at your monitor’s ratio, so an entire audit can be conducted at DPR 2 while the traffic that matters is DPR 3. Fix: always tick Add device pixel ratio and set the field explicitly, or drive the audit from Playwright’s deviceScaleFactor, which is set per context and cannot silently inherit anything.
2. Reading the src attribute instead of currentSrc
The Elements tree shows the markup, not the outcome; src is the fallback URL and stays constant no matter which candidate wins. Fix: read currentSrc from the Properties tab, or $0.currentSrc in the Console with the element selected. If the image is inside a <picture>, currentSrc is also the only value that reflects a <source> having replaced the <img> source set.
3. Measuring before lazy images have resolved
A loading="lazy" image that has never intersected the viewport has an empty currentSrc and a naturalWidth of 0, which a naive audit reports as a broken candidate. Fix: scroll the page to the bottom, wait for the network to settle, and filter on img.complete && img.currentSrc before recording anything — exactly what the sweep script above does before it reads any properties.
4. Treating an empty Network panel as a pass
Every reused candidate is invisible on the wire, and the reuse rule prefers larger cached files, so the warm path systematically hides over-fetching. Fix: run the first pass in a fresh incognito window or a new Playwright context, and treat any (memory cache) or (disk cache) size value as “not measured” rather than “fine”.
5. Assuming Lighthouse’s “Properly size images” is the whole audit
That audit compares the delivered file’s natural size to its rendered size at the single viewport and DPR Lighthouse emulated. It cannot see a candidate that no profile reaches, it cannot detect a <source> overriding the <img>, and it will not flag a sizes value that happens to be correct at the tested breakpoint and wrong at every other one. Fix: keep the Lighthouse audit as a byte-level backstop and use the matrix sweep for correctness; if you want the byte budget enforced too, wire it next to Lighthouse CI budget enforcement for image weight.
Tradeoff: the sweep costs one full page load per profile, so a six-profile matrix on a heavy page runs for tens of seconds. Run the full matrix nightly and a two-profile subset — your narrowest high-DPR phone and your widest desktop — on every pull request; those two bracket the range and catch the majority of sizes regressions.
Related
- Mastering srcset and sizes for Responsive Layouts — the selection algorithm, attribute reference and
<picture>composition rules this audit checks against - Density Descriptors vs Width Descriptors in srcset — which descriptor family an image should use, and the arithmetic the sweep script re-implements
- How to Calculate Optimal sizes Attribute Values — deriving the
sizesstring that a predicted-versus-actual mismatch tells you to fix - Debugging fetchpriority Conflicts in Chrome DevTools — the companion Network-panel workflow for when the right candidate is chosen but fetched late