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.
Where the winning candidate appears in DevTools An illustration of two DevTools surfaces. The upper pane is the Elements Properties view listing currentSrc, naturalWidth, srcset and sizes for the selected image. The lower pane is the Network table with Name, Initiator, Size and Priority columns, including one row served from the memory cache. Three callouts on the right explain that currentSrc names the resolved winner, that an Initiator of Parser proves the preload scanner made the choice, and that a memory-cache row means no selection evidence reached the network. Elements Network Console Properties — selected <img class="card"> currentSrc "/img/card-1080.avif" naturalWidth 1080 srcset "…480w, 720w, 1080w, 1440w" sizes "(max-width:639px) 100vw, 33vw" Network Name Initiator Size Priority card-1080.avif Parser 84 kB High avatar-96.png app.js:214 12 kB Low hero-1440.avif Parser (memory) High currentSrc is the resolved winner. It already accounts for a <source> beating the <img>; the src attribute does not. Initiator "Parser" = the preload scanner chose it. A file name means script did. A "(memory)" row means the candidate was reused, not re-selected. Re-test cold. Four columns, three different failure modes — read all of them before concluding the markup is correct.

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.

Candidate reachability across a device matrix A horizontal axis of required physical pixels from zero to 2400. Six bands mark the range each candidate serves: 320w up to 320, 480w to 480, 720w to 720, 1080w to 1080, 1440w to 1440 and 2160w to 2160. Six numbered dots place the device profiles at 451, 628, 720, 984, 1074 and 1220 required pixels. The 320w and 2160w bands contain no dots, so no profile can select those two files. Which candidate serves which required-pixel range Each band is the span of required physical pixels a single candidate satisfies. Numbered dots are the six device profiles from the sweep above. 320w 480w 720w 1080w 1440w 2160w upscaled 5 4 3 1 2 6 0 600 1200 1800 2400 required physical pixels = rendered slot × devicePixelRatio 1 — 360 px vp × DPR 3 → 984 px → 1080w 2 — 390 px vp × DPR 3 → 1074 px → 1080w 3 — 768 px vp × DPR 2 → 720 px → 720w 4 — 1024 px vp × DPR 2 → 628 px → 720w 5 — 1440 px vp × DPR 1 → 451 px → 480w 6 — 1920 px vp × DPR 2 → 1220 px → 1440w 320w and 2160w hold no profile — two files, two cache keys and two build steps that nothing can select.

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

Cold cache versus warm cache: same selection, different outcome Two parallel timelines. Both run the same three steps: the preloader parses srcset and sizes, computes a 451 pixel slot at device pixel ratio 1, and selects the 480w candidate. On the cold timeline the browser issues a request for card-480.avif. On the warm timeline a previously cached 1440w file satisfies the element instead, so no request appears in the Network panel. Identical arithmetic, different bytes painted Cold cache incognito preloader reads srcset + sizes slot 451 px × DPR 1 = 451 px needed smallest candidate ≥ 451 → 480w GET card-480.avif 200 · 31 kB · visible in Network Warm cache 1440w present preloader reads srcset + sizes slot 451 px × DPR 1 = 451 px needed smallest candidate ≥ 451 → 480w cached 1440w reused no request · no Network row The engine never downgrades to a smaller candidate it would have to fetch — so an empty Network panel is not evidence of a correct source set. Every candidate audit has to start from a cold cache.

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.