Responsive images with srcset: which file the browser picks

srcset hands the browser a list of files and lets it choose. Knowing how it chooses is what makes sizes, x descriptors and the picture element predictable.

Responsive images in HTML mean offering the browser several files and letting it load the one that fits. You list the files in srcset, describe the display width in sizes, and the browser picks one before the page is laid out.

If an image only overflows its column, that is a CSS fix, covered in making an image responsive with max-width. This guide is about the other half: which file arrives, and why.

Try it first. Move the slider to change the sizes value and watch which file the browser chooses.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>srcset with w descriptors and sizes</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: block; font-size: 14px; margin-bottom: 6px; }
  input[type=range] { width: 100%; }
  .stats { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; font-size: 13px; }
  .stats span { background: #fff; border-radius: 6px; padding: 5px 8px; }
  .stats b { color: #0f5132; }
  ul { list-style: none; padding: 0; margin: 0 0 10px; display: flex; flex-wrap: wrap; gap: 6px; font: 13px ui-monospace, Consolas, monospace; }
  li { padding: 4px 8px; border-radius: 6px; background: #e4e7ec; color: #6b7280; }
  li.on { background: #0f5132; color: #fff; }
  code { display: block; font: 12px ui-monospace, Consolas, monospace; background: #fff; border-radius: 6px; padding: 7px 9px; margin-bottom: 10px; overflow-wrap: anywhere; }
  img { display: block; max-width: 100%; height: auto; border-radius: 8px; }
</style>
</head>
<body>
<label for="slot">Display width (the <b>sizes</b> value): <b id="slotOut"></b></label>
<input id="slot" type="range" min="200" max="900" step="20" value="360">

<div class="stats">
  <span>Screen density: <b id="dpr"></b></span>
  <span>Pixels needed: <b id="need"></b></span>
  <span>Browser chose: <b id="chosen">...</b></span>
</div>
<ul id="list"></ul>
<code id="markup"></code>
<div id="stage"></div>

<script>
  const widths = [320, 640, 960, 1280];  // the files you exported, by pixel width
  const colors = ['#fde2da', '#cfe8ff', '#d4f5dc', '#fff3a8'];
  const slot = document.getElementById('slot');
  let urls = [];

  // Stand-in "photo" file: an SVG that prints its own width
  function file(w, i) {
    const h = Math.round(w * 0.4);
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
      <rect width="100%" height="100%" fill="${colors[i]}"/>
      <text x="50%" y="58%" font-family="sans-serif" font-size="${h * 0.4}" text-anchor="middle">${w}w</text></svg>`;
    // blob: URLs behave like real files here (see the article for data: URIs)
    return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
  }

  function render() {
    const px = Number(slot.value);
    const dpr = window.devicePixelRatio;
    // New file URLs each time, like a first visit: nothing cached to reuse
    urls.forEach((u) => URL.revokeObjectURL(u));
    urls = widths.map(file);

    const img = new Image();
    img.alt = 'Sample image';
    img.sizes = px + 'px';
    img.srcset = urls.map((u, i) => `${u} ${widths[i]}w`).join(', ');
    img.style.width = px + 'px';

    const now = urls;
    img.addEventListener('load', () => {
      const i = now.indexOf(img.currentSrc);  // currentSrc = the file actually used
      document.getElementById('chosen').textContent = widths[i] + 'w';
      document.querySelectorAll('#list li').forEach((li, n) => li.classList.toggle('on', n === i));
      document.querySelectorAll('#list li').forEach((li, n) => {
        li.textContent = `${widths[n]}w = ${(widths[n] / px).toFixed(2)}x`;
      });
    });

    document.getElementById('stage').replaceChildren(img);
    document.getElementById('slotOut').textContent = px + 'px';
    document.getElementById('dpr').textContent = dpr + 'x';
    document.getElementById('need').textContent = Math.round(px * dpr) + 'px';
    document.getElementById('markup').textContent =
      `srcset="photo-320.jpg 320w, photo-640.jpg 640w, photo-960.jpg 960w, photo-1280.jpg 1280w" sizes="${px}px"`;
  }

  document.getElementById('list').innerHTML = widths.map(() => '<li></li>').join('');
  slot.addEventListener('input', render);
  render();
</script>
</body>
</html>
Four files, 320w to 1280w. The green one is the file in currentSrc for this screen.

The same slider position picks a bigger file on a high-density phone than on a basic monitor. That is the whole idea: one <img>, a different download per screen.

The markup, line by line

A complete responsive <img> has four parts:

<img
  src="photo-960.jpg"
  srcset="photo-640.jpg 640w, photo-960.jpg 960w, photo-1280.jpg 1280w"
  sizes="(max-width: 700px) 100vw, 700px"
  width="1280" height="720"
  alt="Harbour at sunrise">
  • srcset lists the files. 960w is a fact about the file: it is 960 pixels wide. It is not a rule about when to use it.
  • sizes says how wide the image will be drawn. Conditions are checked left to right. The first true one wins, and the last value, without a condition, is the default.
  • src is the fallback for anything that ignores srcset.
  • width and height give the proportions, so the page does not jump when the file arrives.

How the browser picks a file

The browser has to choose before CSS has placed the image. That is why sizes exists: it is your promise about the layout, made early.

Slot width times screen density gives the pixels needed. The smallest file that covers it is the usual pick.
Slot width times screen density gives the pixels needed. The smallest file that covers it is the usual pick.
  1. Find the slot width from sizes. Here, 360 CSS pixels.
  2. Multiply by the screen's device pixel ratio. A density of 2 needs 720 pixels.
  3. Choose from srcset. In our Chrome tests, it was always the smallest file at least that wide, here 960w.

The specification does not force that last step. It lets the browser weigh other things, so treat "smallest that covers" as the usual result, not a guarantee.

x descriptors: for images with a fixed size

When an image is always drawn at the same size, such as a 160 pixel logo, the width of the layout does not matter. Only the screen density does. That is what x descriptors are for.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>srcset with x descriptors</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
  #logo { width: 160px; height: 80px; border-radius: 10px; background: #fff; }
  .out { font-size: 14px; line-height: 1.7; }
  .out b { color: #0f5132; }
  pre { font: 12px/1.5 ui-monospace, Consolas, monospace; background: #fff; border-radius: 8px; padding: 10px; margin: 12px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<div class="row">
  <img id="logo" alt="Logo" width="160" height="80">
  <div class="out">
    This screen: <b id="dpr"></b><br>
    File shown: <b id="chosen">...</b>
  </div>
</div>
<pre>&lt;img src="logo-160.png"
     srcset="logo-320.png 2x, logo-480.png 3x"
     width="160" height="80" alt="Logo"&gt;</pre>

<script>
  // Three versions of one logo: 160, 320 and 480 pixels wide
  const names = ['logo-160.png (src, counts as 1x)', 'logo-320.png (2x)', 'logo-480.png (3x)'];
  const urls = [160, 320, 480].map((w) => {
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${w / 2}">
      <rect width="100%" height="100%" fill="#cfe8ff"/>
      <text x="50%" y="62%" font-family="sans-serif" font-size="${w / 5}" text-anchor="middle">${w}px</text></svg>`;
    return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
  });

  const logo = document.getElementById('logo');
  logo.addEventListener('load', () => {
    // currentSrc tells you which candidate the browser used
    document.getElementById('chosen').textContent = names[urls.indexOf(logo.currentSrc)];
  });
  logo.srcset = `${urls[1]} 2x, ${urls[2]} 3x`;
  logo.src = urls[0];
  document.getElementById('dpr').textContent = window.devicePixelRatio + 'x';
</script>
</body>
</html>
A 160px logo. A 1x screen gets the src file, 2x and 3x screens get the sharper files.
<img src="logo-160.png"
     srcset="logo-320.png 2x, logo-480.png 3x"
     width="160" height="80" alt="Logo">

The src file counts as the 1x candidate, so you do not need to repeat it. sizes is not used with x descriptors.

x descriptors for fixed-size images, w descriptors plus sizes for images that change width with the layout.
x descriptors for fixed-size images, w descriptors plus sizes for images that change width with the layout.
x descriptors w descriptors
The number means Screen density the file is for Real pixel width of the file
Needs sizes No Yes, or it assumes 100vw
Good for Logos, icons, avatars Photos in a fluid layout
Mix in one srcset Not allowed Not allowed

The picture element: media and type

srcset offers the same image at different sizes. <picture> lets you offer different images: a square crop for phones, or a newer file format with a fallback.

Sources are checked in order. The first one whose type and media both match is used.
Sources are checked in order. The first one whose type and media both match is used.

The browser reads the <source> elements from top to bottom:

  • type names a file format. If the browser cannot decode it, that source is skipped.
  • media is a media query. If it is false for the current window, that source is skipped.
  • The first source that passes both checks is used. Later sources are not considered.
  • The <img> inside is required. It supplies the fallback file, the alt text, and the element that is actually drawn and styled.

Each <source> can carry its own srcset and sizes, so art direction and size choice combine. Media queries work the same way here as in CSS, covered in media queries.

Reading currentSrc

The attribute values never change, so reading img.src tells you nothing about the choice. img.currentSrc holds the URL the browser actually used, and it updates when a different file loads.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>picture with media and type</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: block; font-size: 14px; margin-bottom: 6px; }
  input[type=range] { width: 100%; }
  ol { font: 12.5px/1.5 ui-monospace, Consolas, monospace; background: #fff; border-radius: 8px; padding: 10px 10px 10px 34px; margin: 10px 0; }
  li { padding: 2px 4px; border-radius: 4px; overflow-wrap: anywhere; }
  li.skip { color: #9aa3b2; text-decoration: line-through; }
  li.win { background: #d6f2df; color: #0f5132; font-weight: 700; }
  .out { font-size: 14px; margin-bottom: 10px; }
  .out b { color: #0f5132; }
  picture img { display: block; max-width: 100%; max-height: 220px; width: auto; height: auto; border-radius: 8px; }
</style>
</head>
<body>
<label for="bp">Phone breakpoint: <b id="bpOut"></b> (this frame is <b id="vw"></b> wide)</label>
<input id="bp" type="range" min="300" max="1000" step="10" value="480">

<ol id="steps">
  <li class="skip">&lt;source type="image/x-future" ...&gt; skipped: unknown format</li>
  <li id="s2"></li>
  <li id="s3">&lt;img src="hero-wide.png" alt="..."&gt;</li>
</ol>
<div class="out">currentSrc: <b id="chosen">...</b></div>

<picture>
  <source id="future" type="image/x-future">
  <source id="phone">
  <img id="hero" alt="Sample hero image">
</picture>

<script>
  // Build a stand-in image as an SVG data: URI
  function art(w, h, color, label) {
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
      <rect width="100%" height="100%" fill="${color}"/>
      <text x="50%" y="56%" font-family="sans-serif" font-size="${h / 8}" text-anchor="middle">${label}</text></svg>`;
    return 'data:image/svg+xml,' + encodeURIComponent(svg);
  }
  const files = {
    [art(640, 320, '#fde2da', 'hero-future')]: 'hero-future (never used)',
    [art(300, 300, '#d4f5dc', 'hero-square.png')]: 'hero-square.png',
    [art(640, 220, '#cfe8ff', 'hero-wide.png')]: 'hero-wide.png',
  };
  const [future, square, wide] = Object.keys(files);

  const bp = document.getElementById('bp');
  const phone = document.getElementById('phone');
  const hero = document.getElementById('hero');
  document.getElementById('future').srcset = future;  // browser does not know this type, skips it
  phone.srcset = square;
  hero.src = wide;

  function update() {
    // The first <source> whose media and type match wins; otherwise the <img>
    phone.media = `(max-width: ${bp.value}px)`;
    document.getElementById('bpOut').textContent = bp.value + 'px';
    document.getElementById('vw').textContent = window.innerWidth + 'px';
    document.getElementById('s2').textContent =
      `<source media="(max-width: ${bp.value}px)" srcset="hero-square.png">`;
  }
  function report() {
    const name = files[hero.currentSrc];
    document.getElementById('chosen').textContent = name;
    document.getElementById('s2').className = name === 'hero-square.png' ? 'win' : '';
    document.getElementById('s3').className = name === 'hero-wide.png' ? 'win' : '';
  }

  hero.addEventListener('load', report);  // fires each time the chosen file changes
  bp.addEventListener('input', update);
  window.addEventListener('resize', update);
  update();
</script>
</body>
</html>
Move the breakpoint past this frame's width and the square crop takes over. The first source is skipped as an unknown type.
const img = document.querySelector('picture img');
img.addEventListener('load', () => console.log(img.currentSrc));

The load event fires each time a new file is chosen, so it is the right moment to read currentSrc. The Network panel in the developer tools shows the same thing from the other side: one file downloaded.

Details that trip people up

Three behaviours from our own testing are worth knowing before you debug.

The browser may keep a bigger file. When we shrank the slot in Chrome after a large file had loaded, it kept showing the large file. It already had it. Check the small case in a fresh tab opened at the small size.

Test files as data: URIs mislead. With every candidate written as a data: URI, Chrome picked the largest one every time in our test. The demos above build their test images as blob: URLs for that reason, which behaved like normal files.

width and height on the <img> apply to every source. If a phone crop has different proportions, give that <source> its own width and height, or the crop is stretched.

When it does not work

What you see Cause Fix
Phones download the largest file sizes is missing, so it counts as 100vw Write sizes to match the real column width
The wrong file after changing the layout sizes still describes the old layout Update sizes along with the CSS
The choice looks random w and x mixed in one list, which the spec does not allow Use one kind of descriptor per srcset
Shrinking the window keeps the big file The browser reuses a file it already has Test in a fresh tab at the small size
Every test shows the biggest candidate Candidates are data: URIs Test with real files or blob: URLs
The phone crop is squashed width and height on the <img> apply to it Add width and height to that <source>
The <source> for a new format never loads The browser does not support that type Keep a supported fallback in the <img>

Which file loads depends on the screen that opens the page, so the only real test is someone else's phone. A screenshot shows only your own screen's choice.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to see the file their own screen picks, with currentSrc printed next to it. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between w and x descriptors in srcset?

A w descriptor states the real pixel width of the file, and the browser combines it with sizes and the screen density. An x descriptor states the screen density the file is meant for, which suits images shown at one fixed size, such as a logo. One srcset cannot mix the two.

What happens if I leave out sizes?

With w descriptors, a missing sizes counts as 100vw, so the browser assumes the image fills the whole window. In a narrow column that makes it download a larger file than needed.

How do I see which file the browser picked?

Read the image's currentSrc property, for example in the console with document.querySelector('img').currentSrc. The Network panel in the developer tools also lists the one file that was downloaded.

Why does the browser keep the large image when I make the window smaller?

The specification lets the browser choose any candidate, and a file it already has is a reasonable choice. When we tested in Chrome, shrinking the slot kept the larger file. Test the small case in a fresh tab at the small size.

When should I use picture instead of srcset?

Use picture when the files are not the same image at different sizes: a different crop for phones, or a newer format with a fallback. For the same photo at several widths, srcset and sizes on a plain img are enough.

Keep reading