Debugging Picture Source Selection in DevTools

A <picture> element fails silently. When the browser skips a <source> — because its media condition did not match, or because it cannot decode the MIME type in type — nothing is logged, no error is raised, and the Elements panel keeps showing the same markup you wrote. All you see is the wrong crop on screen, or the right crop with unexpectedly large bytes. This page is the diagnostic procedure for answering two questions precisely: which <source> won, and which of the two tests eliminated each of the others. It assumes you already have working markup from art direction with the HTML <picture> element and are debugging why a specific client resolves it differently than you expected.

Prerequisite checklist

The two tests you are debugging

The selection algorithm is a linear scan with early exit, not a best-match search. The browser walks the <source> children in document order and, for each one, evaluates media first; if it does not match, the source is discarded and type is never even parsed. If media matches, the browser then checks whether it can decode the format named in type. The first source that passes both tests wins, its srcset becomes the image’s source set, and every remaining <source> is ignored regardless of how much better it would have been. If no source passes, the terminal <img src> is used.

That “first match wins, ordering is the whole logic” property is why source-selection bugs feel non-local: a change to source 2’s media attribute changes which file source 5 delivers. The elimination trace below is the shape you are trying to reconstruct in DevTools — one verdict per row, with the reason attached.

Source Elimination Trace at 900 CSS Pixels in Safari 14 A four-column table listing five source elements and the terminal img in document order. For each row it records the media test result, the type test result, and the verdict. The first two sources fail the media test because the viewport is 900 pixels and they require 1024. The third passes media but fails the type test because Safari 14 has no AVIF decoder. The fourth passes both and is selected. The fifth is never reached and the terminal img only paints the selected source. Client under test: viewport 900 CSS px, Safari 14 (no AVIF decoder), DPR 2 Candidate, in document order media test type test verdict 1 <source media="(min-width: 1024px)" type="image/avif" srcset="hero-lg.avif"> 900 < 1024 not parsed discarded 2 <source media="(min-width: 1024px)" type="image/webp" srcset="hero-lg.webp"> 900 < 1024 not parsed discarded 3 <source media="(min-width: 640px)" type="image/avif" srcset="hero-md.avif"> 900 ≥ 640 no decoder discarded, no log 4 <source media="(min-width: 640px)" type="image/webp" srcset="hero-md.webp"> 900 ≥ 640 WebP ok SELECTED 5 <source type="image/avif" srcset="hero-sm.avif"> never reached img <img src="hero-fallback.jpg" width="480" height="640"> paints row 4 currentSrc reports hero-md.webp. The Network panel shows one request. Nothing anywhere reports the three eliminations.

Reconstructing that table by eye is unreliable once a page has more than one <picture>, so the first thing to do is generate it.

When the inspected DOM is not the DOM that was selected from

Before generating anything, rule out the failure mode that invalidates every subsequent measurement. React, Vue and Astro islands routinely replace a server-rendered <picture> during hydration. When that happens the browser runs selection twice — once against the markup the parser saw, once against whatever the client re-rendered — and the Elements panel shows you only the second tree. A <source> that existed at parse time, won, downloaded its bytes, and was then removed by hydration is completely invisible to inspection, yet it is the one that cost you the transfer.

Two signals expose the divergence. The Network panel lists a request for a file that appears nowhere in the current DOM, and performance.getEntriesByType('resource') reports that same URL with an initiatorType of img. Confirm by fetching the unmodified document:

# The markup the parser actually saw, before any framework touched it.
curl -s https://example.com/gallery/ | grep -A 12 '<picture'

If that output and the Elements panel disagree about the number or order of <source> children, fix the hydration mismatch first — the component-level controls for it are covered in Next.js image component optimization and in the Astro picture component for AVIF and WebP. Everything below assumes the served markup and the live DOM agree.

The audit snippet

Paste this into the Console, or save it under Sources → Snippets as picture-audit so it survives navigations. It rebuilds the elimination trace for every <picture> on the page by replaying each media condition through matchMedia and matching every candidate URL against currentSrc.

// picture-audit.js — Console snippet. Prints one table per <picture>.
function auditPictures(root = document) {
  // The media conditions were evaluated against this width, in CSS pixels.
  // clientWidth excludes the classic scrollbar; matchMedia includes it in
  // Chrome/Safari, so a 15 px disagreement here is normal and harmless.
  const vw  = document.documentElement.clientWidth;
  const dpr = window.devicePixelRatio;   // affects srcset x/w picking, NOT source picking

  for (const pic of root.querySelectorAll('picture')) {
    const img = pic.querySelector('img');
    if (!img) { console.warn('<picture> with no terminal <img>:', pic); continue; }

    // currentSrc is the ONLY ground truth: the absolute URL of the candidate the
    // browser committed to. img.src still reports the fallback attribute even when
    // a <source> won, which is why reading src produces false "it never switched"
    // conclusions. Empty string means selection has not run yet.
    const chosen = img.currentSrc;

    const rows = [...pic.querySelectorAll('source')].map((s, i) => {
      const media = s.getAttribute('media') ?? '';   // '' = unconditional, always matches
      const type  = s.getAttribute('type')  ?? '(any)';

      // matchMedia re-evaluates the identical condition against the identical
      // viewport, so a false here is proof the media test did the eliminating.
      const mediaOk = media === '' ? true : window.matchMedia(media).matches;

      // Strip the descriptors ("hero.avif 800w" -> "hero.avif") and resolve each
      // candidate to an absolute URL so it is comparable with currentSrc.
      const urls = (s.getAttribute('srcset') ?? '')
        .split(',')
        .map(c => c.trim().split(/\s+/)[0])
        .filter(Boolean)
        .map(u => new URL(u, document.baseURI).href);

      return {
        '#': i + 1,
        media: media || '(none)',
        type,
        mediaOk,
        candidates: urls.length,
        // Deduction, not observation: if media matched and this row still lost,
        // the type test is the only remaining thing that could have removed it.
        verdict: !mediaOk            ? 'skipped — media'
               : urls.includes(chosen) ? 'SELECTED'
               : 'skipped — type unsupported',
      };
    });

    rows.push({
      '#': 'img', media: '(fallback)', type: '(any)', mediaOk: true, candidates: 1,
      verdict: chosen === img.src ? 'SELECTED (fallback)' : 'not used',
    });

    console.groupCollapsed(`<picture> → ${chosen.split('/').pop() || '(unresolved)'}`);
    console.table(rows);
    console.log(
      'viewport', vw, 'CSS px · DPR', dpr,
      '· layout box', `${img.clientWidth}×${img.clientHeight}`,
      // A large gap between layout box and intrinsic size means the *right* source
      // was chosen but the *wrong* srcset candidate inside it — a `sizes` problem,
      // not a source-selection problem.
      '· intrinsic', `${img.naturalWidth}×${img.naturalHeight}`,
    );
    console.groupEnd();
  }
}

auditPictures();
// Watch verdicts move as you drag the window across a breakpoint:
addEventListener('resize', () => { console.clear(); auditPictures(); }, { passive: true });

The verdict column is what turns a vague “the wrong image loaded” into a one-line fix. skipped — media means your breakpoint arithmetic is wrong and the file is irrelevant. skipped — type unsupported means the breakpoint is right and the file is missing, mislabelled, or genuinely undecodable on this client. Those two failures have nothing in common and are frequently confused.

Verification steps

Each DevTools surface answers exactly one of the questions above, and none of them answers more than one. Cross two of them before concluding anything.

Four DevTools Surfaces, Four Different Questions A two by two panel map. The Elements Properties pane answers which URL won. The Network panel answers whether the bytes were actually fetched. The device toolbar and Rendering panel answer whether the condition flips at all. The Console with matchMedia answers why a given source lost. Each panel lists three caveats. One selection bug, four partial witnesses Elements → Properties → currentSrc Which URL actually won? the only ground truth; src misleads re-reads live after every swap empty until selection has run Network → Img filter Were the bytes really fetched? one row per committed candidate breakpoint crossing adds a row memory-cache hits hide the swap Device toolbar + Rendering Does the condition flip at all? types exact CSS-px widths DPR override changes srcset only emulates prefers-color-scheme Console → matchMedia() Why did this source lose? replays the exact condition separates media from type failure scriptable across many widths No panel sees the type test directly — it is always inferred by elimination from the other three.

1. Read currentSrc at the right moment

Select the terminal <img> in the Elements panel and open the Properties pane (the tab next to Styles, Computed and Layout). Expand the HTMLImageElement entry and read currentSrc. In Safari’s Web Inspector the equivalent is the Node → Properties section of the details sidebar; in Firefox, hover the src attribute value and the tooltip reports the resolved current source.

The trap is timing. currentSrc is populated when the browser commits to a candidate in the “update the image data” algorithm, which is asynchronous relative to parsing. Reading it from a DOMContentLoaded handler, from a framework’s useEffect on first mount, or from a Playwright call that only awaited domcontentloaded will return an empty string on a slow connection and the correct value on a fast one — a textbook flaky test.

When currentSrc Is Safe To Read A horizontal timeline with five markers: the parser reaching the picture element, the preload scanner issuing an early request, source selection committing, the img load event firing, and a later window resize past a breakpoint that re-runs selection. A bracket marks the early window in which currentSrc is still an empty string and a second bracket marks the stable window after load. When currentSrc is safe to read parser reaches <picture> currentSrc is "" preload scanner may fetch a candidate early selection commits media, then type, first wins img load event currentSrc now stable resize past 1024 px selection re-runs, new fetch DOMContentLoaded can land here — currentSrc may still be "" stable until a condition changes state Assert on the load event, never on DOMContentLoaded, and re-assert after any resize that crosses a breakpoint.

2. Force exact widths and watch for the re-fetch

Open the device toolbar (Ctrl/Cmd + Shift + M), switch the dimension dropdown to Responsive, and type exact widths rather than dragging: 1023, then 1024, then 639, then 640. Dragging skips pixels and hides off-by-one errors in min-width versus max-width boundaries, which are the single most common breakpoint bug because (max-width: 640px) and (min-width: 640px) both match at exactly 640.

With Disable cache ticked and the Img filter applied, crossing a breakpoint must add a network row. If currentSrc changes but no row appears, the response came from the memory cache; if no row appears and currentSrc did not change, selection genuinely did not re-run and your media conditions do not describe the boundary you think they do.

Warning: trust the CSS-pixel width the page reports, never the number in the OS window chrome. Docked DevTools shrink the page viewport, and page zoom scales it again — a window 1440 px wide at 110 % zoom presents roughly 1309 CSS px to matchMedia and therefore sits on the wrong side of a 1366 px breakpoint. Read document.documentElement.clientWidth in the Console and check it against the value the audit snippet prints before concluding that a condition is broken.

3. Assert the whole matrix in Playwright

Manual width-typing does not scale past one page. This script encodes the intended selection matrix as data and fails CI when reality diverges:

// tests/picture-selection.spec.js — npx playwright test
import { test, expect } from '@playwright/test';

// The intended matrix: viewport width in CSS px -> expected filename.
const MATRIX = [
  { width: 1440, expect: 'hero-lg' },   // above the 1024 breakpoint
  { width: 1024, expect: 'hero-lg' },   // exactly ON it — min-width is inclusive
  { width: 1023, expect: 'hero-md' },   // one pixel below
  { width:  640, expect: 'hero-md' },
  { width:  639, expect: 'hero-sm' },
  { width:  375, expect: 'hero-sm' },
];

for (const row of MATRIX) {
  test(`picture selects ${row.expect} at ${row.width}px`, async ({ page }) => {
    // Height is irrelevant to min-width conditions but must be set.
    await page.setViewportSize({ width: row.width, height: 900 });
    await page.goto('/gallery/', { waitUntil: 'load' });  // 'load', not 'domcontentloaded'

    const img = page.locator('picture img').first();
    // Wait for the decode to complete: naturalWidth stays 0 until the bytes
    // are decoded, and currentSrc can lag a fraction behind the commit.
    await expect.poll(() => img.evaluate(el => el.naturalWidth)).toBeGreaterThan(0);

    const src = await img.evaluate(el => el.currentSrc);
    expect(src, `viewport ${row.width}px picked ${src}`).toContain(row.expect);
  });
}

Run the same spec under --project=webkit to cover the type half of the matrix. Chromium and WebKit differ on which formats they decode, and that difference is invisible under device emulation — WebKit is the only way to actually exercise an AVIF fallback path. The build-time counterpart of this test is covered in Lighthouse CI budget enforcement for image weight, which catches the case where the correct source is chosen but the file behind it has quietly doubled in size.

4. Confirm the served bytes match the declared type

Source selection is a promise about the response, and the server can break it. If a <source type="image/avif"> is selected but the origin returns Content-Type: application/octet-stream, Chrome will usually still sniff and decode it while Safari will not — producing a broken image on exactly one browser tier.

# Does the response type agree with the type attribute you declared?
curl -sI https://example.com/img/hero-md.avif | grep -i '^content-type'
# Expected: content-type: image/avif

# Is the file actually the format its extension claims? The first 12 bytes of an
# AVIF file contain the "ftypavif" brand; a mislabelled JPEG will show JFIF here.
curl -s --range 0-11 https://example.com/img/hero-md.avif | xxd

Server-side fixes for the header half live in MIME type configuration for modern media servers.

Common mistakes

1. Reading img.src instead of img.currentSrc

src returns the terminal <img> attribute and never changes when a <source> wins. Every “the picture element isn’t working” bug report that turns out to be nothing at all is this one.

// WRONG — always reports the fallback, regardless of which source was selected.
console.log(document.querySelector('picture img').src);   // .../hero-fallback.jpg

// CORRECT — the resolved URL of the candidate the browser committed to.
console.log(document.querySelector('picture img').currentSrc); // .../hero-md.webp

2. Testing format support with device emulation

Selecting “iPhone 14” in the device toolbar changes the user agent string, the viewport and the DPR. It does not remove Chrome’s AVIF decoder, so every AVIF <source> still passes its type test and your Safari fallback path is never executed. Emulation can validate the media half of the matrix and is useless for the type half.

// WRONG — proves nothing about Safari; Chromium decodes AVIF under any UA string.
await page.setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) …');

// CORRECT — run the real engine that lacks the decoder.
// playwright.config.js
projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  { name: 'webkit',   use: { ...devices['Desktop Safari'] } },  // exercises the WebP fallback
];

The corresponding markup pattern is documented in how to configure AVIF fallbacks for Safari 14.

3. Ordering sources narrow-first

Because the scan exits on the first match, a source whose condition is satisfied by every viewport must come last. Putting the narrow condition first makes it win everywhere and renders the wide sources dead code — the audit snippet reports them as skipped — media at wide widths, which looks nonsensical until you notice the ordering.

<!-- WRONG: (min-width: 640px) matches at 1440 too, so hero-lg is unreachable. -->
<source media="(min-width: 640px)"  srcset="/img/hero-md.avif" type="image/avif">
<source media="(min-width: 1024px)" srcset="/img/hero-lg.avif" type="image/avif">

<!-- CORRECT: widest condition first; each later source only sees what is left. -->
<source media="(min-width: 1024px)" srcset="/img/hero-lg.avif" type="image/avif">
<source media="(min-width: 640px)"  srcset="/img/hero-md.avif" type="image/avif">

4. Blaming source selection for a sizes problem

If currentSrc names the right file family but the intrinsic size is far larger than the layout box, selection worked and candidate picking inside the chosen srcset did not. The console.log line in the audit snippet prints both numbers side by side for exactly this reason: a 1600 px intrinsic image in a 400 px box at DPR 1 is a sizes bug, and the arithmetic to fix it is in how to calculate optimal sizes attribute values.

<!-- WRONG: no sizes, so the browser assumes 100vw and picks the largest candidate. -->
<source media="(min-width: 1024px)"
        srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w" type="image/avif">

<!-- CORRECT: sizes describes the real slot, so the 800w candidate wins at DPR 1. -->
<source media="(min-width: 1024px)"
        srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
        sizes="(min-width: 1440px) 720px, 50vw" type="image/avif">

5. Expecting a console error when a type is unsupported

There is none. An undecodable type is a routine skip, not a failure, and neither the Console nor the Issues panel records it. The only observable trace is the absence of a network request for that candidate — which is why step 2 insists on cache being disabled, and why the audit snippet infers the type failure rather than reading it.

If you need a positive signal, attach a temporary onerror handler to the terminal <img>. It is worth understanding exactly when that fires, because it is narrower than people assume: the error event fires only when the selected candidate fails to load or decode, not when a source is skipped. So a page where every AVIF source is skipped and the WebP fallback loads cleanly is, from the event’s point of view, a complete success. That distinction is the whole diagnostic value — an error event means “the chosen bytes are bad”, while a silent wrong image means “the choice itself was wrong”, and the two have entirely different fixes.

// Temporary instrumentation. Distinguishes a bad choice from bad bytes.
document.querySelectorAll('picture img').forEach(img => {
  img.addEventListener('error', () =>
    console.error('selected candidate failed to load:', img.currentSrc || img.src));
  img.addEventListener('load', () =>
    console.info('committed:', img.currentSrc, `${img.naturalWidth}px intrinsic`));
});