How to check which srcset image the browser loaded

Read img.currentSrc in the console or from a script. It holds the file the browser chose, which src and srcset do not. On the probe page below the same img element resolved to hero-400.png at 390 px, hero-1600.png at the same width with DPR 3, and hero-800.png at 1280 px.

Why check this

Run this when responsive images ship, when sizes changes, and after any layout change that alters how wide an image renders. A wrong sizes value costs bytes on every mobile load and nobody notices, because the page looks right.

The defect it prevents is a phone downloading the 1600 px hero for a 390 px slot, or a 400 px file stretched across a 1280 px banner. Both pass a visual review on a desktop monitor, and both show up in one property the Elements panel never lists.

Prerequisites

// srcset-server.mjs   run: node srcset-server.mjs   stop: Ctrl-C
import http from 'node:http';
import { deflateSync } from 'node:zlib';
const table = Array.from({ length: 256 }, (_, n) => {
  let c = n;
  for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
  return c >>> 0;
});
const crc32 = (b) => { let r = ~0; for (const x of b) r = table[(r ^ x) & 255] ^ (r >>> 8); return ~r >>> 0; };
const chunk = (type, data) => {
  const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
  const td = Buffer.concat([Buffer.from(type), data]);
  const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
  return Buffer.concat([len, td, crc]);
};
function png(w, h, grey) {
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 0;
  const raw = Buffer.alloc((w + 1) * h);
  for (let y = 0; y < h; y++) raw.fill(grey, y * (w + 1) + 1, (y + 1) * (w + 1));
  return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
    chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
const page = `<!doctype html><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>srcset probe</title>
<style>body{margin:0;font:16px/1.5 system-ui} img{display:block;max-width:100%;height:auto}</style>
<h1>srcset probe</h1>
<img id="hero" alt="hero"
     src="/img/hero-800.png"
     srcset="/img/hero-400.png 400w, /img/hero-800.png 800w, /img/hero-1600.png 1600w"
     sizes="(max-width: 600px) 100vw, 50vw">
<img id="thumb" alt="thumb" width="200"
     src="/img/thumb-200.png"
     srcset="/img/thumb-200.png 1x, /img/thumb-400.png 2x">
<img id="banner" alt="banner"
     src="/img/banner-400.png"
     srcset="/img/banner-400.png 400w, /img/banner-oops.png 1600w"
     sizes="100vw">
`;
http.createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    return res.end(page);
  }
  const m = /^\/img\/([a-z]+)-([a-z0-9]+)\.png$/.exec(req.url);
  if (m) {
    const w = m[2] === 'oops' ? 400 : Number(m[2]);   // the trap: declared 1600w, 400 px wide
    res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'max-age=600' });
    return res.end(png(w, Math.round((w * 9) / 16), (w % 200) + 40));
  }
  res.writeHead(404, { 'content-type': 'text/plain' });
  res.end('not found');
}).listen(8864, () => console.log('srcset probe on http://localhost:8864/'));

Steps

  1. Step 1.

    Start the probe page. Stop it with Ctrl-C at the end.

    node srcset-server.mjs
    
    srcset probe on http://localhost:8864/

    To read one image by hand, open the page in Chrome, press F12, select the image in the Elements panel and type $0.currentSrc in the Console. The Network panel shows the same answer as a request, and the Elements panel attribute list does not show it at all.

  2. Step 2.

    Load the page three times, each in a fresh browser profile with an empty cache, and record the choice. Save this as srcset-loads.mjs.

    // srcset-loads.mjs
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const cases = [
      { label: '390 x 844, DPR 1', width: 390, height: 844, deviceScaleFactor: 1 },
      { label: '390 x 844, DPR 3', width: 390, height: 844, deviceScaleFactor: 3 },
      { label: '1280 x 800, DPR 1', width: 1280, height: 800, deviceScaleFactor: 1 },
    ];
    for (const c of cases) {
      const browser = await launch({ executablePath: CHROME, headless: true });   // a new profile, so an empty cache
      const page = await browser.newPage();
      const files = [];
      page.on('response', (r) => { if (r.request().resourceType() === 'image') files.push(r.url().split('/').pop()); });
      await page.setViewport(c);
      await page.goto('http://localhost:8864/', { waitUntil: 'networkidle2' });
      console.log(c.label);
      console.log((await page.evaluate(() => [...document.images].map((i) =>
        `  #${i.id.padEnd(7)} currentSrc ${i.currentSrc.replace(location.origin, '').padEnd(22)} naturalWidth ${String(i.naturalWidth).padEnd(5)} layout width ${Math.round(i.getBoundingClientRect().width)}`))).join('\n'));
      console.log('  files fetched: ' + files.join(', '));
      await browser.close();
    }
    
    390 x 844, DPR 1
    #hero    currentSrc /img/hero-400.png      naturalWidth 390   layout width 390
    #thumb   currentSrc /img/thumb-200.png     naturalWidth 200   layout width 200
    #banner  currentSrc /img/banner-400.png    naturalWidth 390   layout width 390
    files fetched: hero-400.png, thumb-200.png, banner-400.png
    390 x 844, DPR 3
    #hero    currentSrc /img/hero-1600.png     naturalWidth 390   layout width 390
    #thumb   currentSrc /img/thumb-400.png     naturalWidth 200   layout width 200
    #banner  currentSrc /img/banner-oops.png   naturalWidth 97    layout width 98
    files fetched: hero-1600.png, thumb-400.png, banner-oops.png
    1280 x 800, DPR 1
    #hero    currentSrc /img/hero-800.png      naturalWidth 640   layout width 640
    #thumb   currentSrc /img/thumb-200.png     naturalWidth 200   layout width 200
    #banner  currentSrc /img/banner-oops.png   naturalWidth 320   layout width 320
    files fetched: hero-800.png, thumb-200.png, banner-oops.png

    The hero moves through all three candidates. At 390 px with DPR 1 the sizes value resolves to 390 CSS px and the 400w file covers it. At DPR 3 the same slot needs 1170 device pixels, so the 1600w file wins. At 1280 px the 50vw branch asks for 640 and the 800w file wins.

    The banner is the interesting row. At DPR 3 it laid out 98 px wide instead of 390.

  3. Step 3.

    Prove that a live resize is not a test. Load at 1280, shrink the viewport, reload, then reload with the cache off. Save this as srcset-resize.mjs.

    // srcset-resize.mjs
    import { launch } from 'puppeteer-core';
    const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
    const page = await browser.newPage();
    const files = [];
    page.on('response', (r) => { if (r.request().resourceType() === 'image') files.push(r.url().split('/').pop()); });
    const wait = (ms) => new Promise((r) => setTimeout(r, ms));
    const hero = () => page.evaluate(() => {
      const i = document.getElementById('hero');
      return `currentSrc ${i.currentSrc.replace(location.origin, '').padEnd(20)} layout width ${Math.round(i.getBoundingClientRect().width)}`;
    });
    const since = (n) => files.slice(n).join(', ') || '(nothing)';
    
    await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
    await page.goto('http://localhost:8864/', { waitUntil: 'networkidle2' });
    await wait(500);
    console.log('load at 1280      ' + (await hero()) + '  requested: ' + since(0));
    
    let n = files.length;
    await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 1 });
    await wait(1000);
    console.log('resize to 390     ' + (await hero()) + '  requested: ' + since(n));
    
    n = files.length;
    await page.reload({ waitUntil: 'networkidle2' });
    await wait(1000);
    console.log('reload at 390     ' + (await hero()) + '  requested: ' + since(n));
    
    n = files.length;
    await page.setCacheEnabled(false);            // the same as ticking Disable cache
    await page.reload({ waitUntil: 'networkidle2' });
    await wait(1000);
    console.log('cache off, reload ' + (await hero()) + '  requested: ' + since(n));
    await browser.close();
    
    load at 1280      currentSrc /img/hero-800.png    layout width 640  requested: hero-800.png, thumb-200.png, banner-oops.png
    resize to 390     currentSrc /img/hero-800.png    layout width 390  requested: (nothing)
    reload at 390     currentSrc /img/hero-800.png    layout width 390  requested: thumb-200.png, hero-800.png, banner-oops.png, hero-400.png, banner-400.png
    cache off, reload currentSrc /img/hero-400.png    layout width 390  requested: hero-400.png, thumb-200.png, banner-400.png

    Line two is the rule most people miss: a browser never swaps down to a smaller candidate it already has. Line three is stronger. The reload at 390 px did request hero-400.png, and still painted hero-800.png from the cache, so the page spent both files and reported the wrong one. Only the run with the cache disabled answered the question that was asked.

  4. Step 4.

    Compare the chosen file against the pixels the layout needs. Save this as srcset-truth.mjs.

    // srcset-truth.mjs
    import { launch } from 'puppeteer-core';
    const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3 });
    await page.goto('http://localhost:8864/', { waitUntil: 'networkidle2' });
    console.log('devicePixelRatio 3, viewport 390');
    console.log((await page.evaluate(async () => {
      const out = [];
      for (const i of document.images) {
        const bmp = await createImageBitmap(await (await fetch(i.currentSrc)).blob());
        const need = Math.round(i.getBoundingClientRect().width * devicePixelRatio);
        out.push(`  #${i.id.padEnd(7)} ${i.currentSrc.split('/').pop().padEnd(16)} naturalWidth ${String(i.naturalWidth).padEnd(5)} file is ${String(bmp.width).padEnd(5)} px wide, the layout needs ${need}`);
      }
      return out;
    })).join('\n'));
    await browser.close();
    
    devicePixelRatio 3, viewport 390
    #hero    hero-1600.png    naturalWidth 390   file is 1600  px wide, the layout needs 1170
    #thumb   thumb-400.png    naturalWidth 200   file is 400   px wide, the layout needs 600
    #banner  banner-oops.png  naturalWidth 97    file is 400   px wide, the layout needs 293

    naturalWidth never reports the size of the file. It reports the file width divided by the density the descriptor implies, so for the hero it says 390 for a 1600 px image. createImageBitmap reads the decoded bitmap and gives the real number. The thumb has no candidate above 2x, so it serves 400 px into a slot that needs 600 and nothing in the DOM says so.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | currentSrc is the smallest candidate on a wide viewport | sizes describes a narrower slot than the layout uses | Compare sizes against the measured getBoundingClientRect().width at that width | | currentSrc does not change after a resize | The browser kept a larger candidate it already holds | Reload with the cache off, or use a fresh profile, before recording the answer | | naturalWidth matches the layout width exactly | The descriptor, not the file, produced that number | Read the real width with createImageBitmap, as step 4 does | | The file is narrower than layout width times DPR | The image is upscaled on screen | Add a larger candidate, or accept it and record the decision | | The element lays out far narrower than its container | A w descriptor larger than the file shrank the intrinsic size | Fix the descriptor to the real pixel width of the file |

Common mistakes

Sign: Dragging the window narrower shows the same image, so srcset looks broken.Cause: Selection never downgrades. After a resize from 1280 to 390 the hero stayed on hero-800.png and no request was made. Even the reload kept it, while fetching hero-400.png as well. Only a reload with the cache disabled reported hero-400.png.
Sign: naturalWidth says the image is exactly the right size.Cause: For a w descriptor the browser divides the file width by the density it computed, so hero-1600.png reported naturalWidth 390 and the miscoded banner reported 97 for a 400 px file. Fetch currentSrc and decode it with createImageBitmap when you need the file's real width.
Sign: One image renders far smaller than its container and the CSS looks correct.Cause: A w descriptor that overstates the file makes Chrome compute a small intrinsic size. The banner declared 1600w for a 400 px file, so at DPR 3 it laid out 98 px wide inside a 390 px slot. The descriptor is a promise about the file, and nothing verifies it for you.
Sign: currentSrc names the largest candidate, so the image is assumed sharp.Cause: The largest candidate can still be too small. At DPR 3 the 2x thumb was the best available, giving 400 px where the layout needed 600. Compare the decoded width against layout width times devicePixelRatio, not against the candidate list.

What to check next

FAQ

How do I check that responsive images with srcset are working?

Load the page at a fresh profile per width, then read currentSrc for each image and compare it against the file the layout needs at that width and device pixel ratio. Step 2 does all three widths in one run.

Where does Chrome DevTools show which srcset candidate loaded?

The Network panel shows the request, and hovering the src attribute in the Elements panel shows a preview with the intrinsic size. Neither lists currentSrc. Select the image and type $0.currentSrc in the Console for a direct answer.

Why does the browser keep the large image after I resize the window?

The selection algorithm may keep a candidate that is already available, and Chrome does. That avoids a second download for a size it can already paint. It also means a resized live page reports the choice made at the old width.

Does srcset depend on the device pixel ratio?

Yes. With w descriptors the browser multiplies the resolved sizes value by the device pixel ratio. The same 390 px slot picked the 400w file at DPR 1 and the 1600w file at DPR 3.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny