Debugging fetchpriority Conflicts in Chrome DevTools

When competing resource hints force Chrome’s network scheduler to negotiate multiple Highest-priority requests simultaneously, critical media assets stall — inflating LCP and masking the gains that fetchpriority on the LCP image was supposed to deliver. This page is a deterministic, DevTools-first workflow for isolating, diagnosing, and resolving those conflicts. It builds directly on the Lazy Loading, Preloading & Fetch Priorities reference.

Prerequisite checklist

Before opening DevTools, confirm the following are already in place:

Each checklist item exists because Blink resolves a declared hint into a computed priority through a small set of rules, and several common markup combinations collapse a high declaration back down to Low. The matrix below is the mapping you are checking against every time you read the Priority column — memorise it and most conflicts become obvious before you open the Network panel at all.

Markup Combination to Computed Priority Matrix A four-column table mapping five common image markup combinations to whether the preload scanner discovers them, the priority Chrome computes internally, and whether that outcome helps or hurts the Largest Contentful Paint image. Markup combination Preload scanner Computed priority Outcome <img fetchpriority="high" loading="eager"> discovers it Highest fetched first <img fetchpriority="high" loading="lazy"> skips it Low, deferred hint ignored <img> in viewport, no hint at all discovers it Low, raised to High after first layout late start <link rel="preload"> plus <img fetchpriority="high"> discovers twice Highest twice stream contention CSS background-image: url(hero.webp) never sees it Low waits for CSSOM Only row one produces a Highest-priority request that the scheduler can actually dispatch without contention.

Rows two and five are the two silent failures: the markup looks correct in review, the attribute is present in the DOM, and yet the request never reaches Highest. Row four is the loud failure this page spends most of its time on, because it is the one that produces a stalled Highest request rather than a demoted one.

Exact solution: Network panel priority audit

The Network panel’s Priority column is the single authoritative view of what the scheduler actually assigned to each request. This is distinct from what you declared in HTML — Chrome can and does override hints.

Step 1 — Open DevTools:
  F12 (Windows/Linux) or Cmd + Option + I (macOS)
  Navigate to the Network tab.

Step 2 — Enable the Priority column:
  Right-click any column header → tick "Priority".
  Sort the column descending (Highest → Low).

Step 3 — Simulate field conditions:
  Throttling dropdown → "Fast 3G"
  Tick "Disable cache" (bypasses both HTTP cache and service worker).

Step 4 — Reload:
  Cmd/Ctrl + Shift + R (hard reload, skips disk cache).

Step 5 — Identify stalled high-priority requests:
  Filter by "Img" type. Look for any row where:
    Priority = Highest   AND   Stalled column > 500 ms
  These are priority inversions — assets the scheduler wanted to
  fetch first but could not due to connection-pool contention or
  a conflicting directive on the same resource.

The diagram below shows a typical priority-inversion waterfall before and after removing a redundant preload:

fetchpriority waterfall — before and after removing redundant preload Two side-by-side network waterfalls. On the left, hero.webp shows a long Stalled bar before the actual download begins. On the right, after removing the duplicate preload directive, hero.webp starts downloading immediately with no stall. Before — duplicate preload hero.webp Highest 620 ms preload hero.webp Highest Queue hero-alt.webp High LCP ~2.8 s After — one fetchpriority="high" hero.webp Highest 380 ms hero-alt.webp High LCP ~1.9 s Stalled Connection queue Downloading Removing the duplicate preload link eliminated the 620 ms stall and cut LCP by ~0.9 s. Simulated on Fast 3G throttling (40 Mbit/s down, 20 ms RTT) in Chrome 124.

Correct HTML pattern — single directive, no duplication

<!--
  fetchpriority="high"  — tells the preload scanner this is the LCP image.
                          Apply to AT MOST ONE image per page; multiple "high"
                          declarations trigger starvation in the H2 connection pool.
  loading="eager"       — required when fetchpriority="high" is set; "lazy" silently
                          overrides the priority hint and defers the fetch.
  decoding="async"      — offloads JPEG/WebP/AVIF decompression off the main thread
                          so it does not block INP.
  width/height          — prevent layout shift (CLS); always include both.
-->
<img
  src="/hero.webp"
  fetchpriority="high"
  loading="eager"
  decoding="async"
  alt="Primary hero visual"
  width="1200"
  height="630"
/>

Warning: Do not also add <link rel="preload" as="image" href="/hero.webp"> when fetchpriority="high" is already set on the <img>. The preload link creates a second Highest-priority scheduler entry for the same byte range. Chrome issues both fetches, collides on the same H2 stream slots, and the LCP image ends up stalled behind its own preload.

Tracing that collision through Blink’s internals makes the failure concrete. The preload scanner runs ahead of the main HTML parser and emits one scheduler entry per hint it finds; it has no de-duplication step across different hint sources, so a <link> in <head> and an <img> further down the document produce two independent entries that only converge later, at the connection layer:

How a Duplicate Hint Becomes Two Scheduler Entries A four-lane sequence diagram. The HTML parser feeds the preload scanner, which enqueues one entry for the preload link and a second for the image element, both at Highest priority. The HTTP/2 connection receives two requests for the same bytes and reports the second one stalled behind the first. HTML parser Preload scanner Scheduler HTTP/2 socket hand off raw tokens ahead of DOM build entry A: preload link hero.webp — Highest entry B: img element hero.webp — Highest dispatch A and B onto the same origin pool identical byte range requested twice B reported Stalled 620 ms Fix: delete entry A. One hint per asset means one scheduler entry and no self-contention.

The asymmetry worth remembering is that the scheduler treats the two entries as peers, not as a request and its cache warm-up. Chrome’s memory cache does eventually collapse them into a single network fetch when the URLs match byte-for-byte, but the collapse happens after both entries have already claimed stream slots — which is why the Stalled segment appears even though only one response body is ever transferred. Any difference in the URLs at all (a ?v= query string on one of them, a different crossorigin value, a protocol-relative href) defeats the collapse entirely and you pay for the image twice. Cross-check the resolved URLs with the console audit below before assuming the two entries are identical.

Verification steps

1. Console DOM audit

Run this in the DevTools Console to surface all competing high-priority hints declared in the live DOM:

// Maps every declarative fetchpriority=high image AND every preload-as-image
// link to a structured table. Look for duplicate src/href values — they indicate
// a redundant hint that will cause scheduler contention.
const hints = Array.from(
  document.querySelectorAll(
    'img[fetchpriority="high"], link[rel="preload"][as="image"]'
  )
).map(el => ({
  tag:      el.tagName,
  src:      el.src || el.href,            // canonical URL after resolution
  priority: el.getAttribute('fetchpriority') || '(none)',
  loading:  el.getAttribute('loading')   || 'auto',
  // Note: dynamically injected hints (e.g. from framework hydration) will
  // appear here IF they were already added at query time. For SSR hydration
  // races, attach a MutationObserver before DOMContentLoaded fires.
}));

console.table(hints);
// Red flag: two rows with the same src/href value.

2. Automated CI check with Puppeteer

For regression testing in CI, capture stalled image timings headlessly before merging. Run this alongside a weight budget — the two catch different regressions, and Lighthouse CI budget enforcement for image weight covers the byte-size half of the problem that a stall check cannot see:

// Puppeteer: report image requests where sendStart > 400 ms — the threshold
// above which scheduler stalls materially inflate LCP on mobile connections.
// npm install puppeteer
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox'],        // required in most CI containers
  });
  const page = await browser.newPage();

  // Emulate a mid-range Android device on Fast 3G
  await page.emulate(puppeteer.KnownDevices['Moto G Power']);
  await page.emulateNetworkConditions(
    puppeteer.networkConditions['Fast 3G']  // 40 Mbps down, 20 ms RTT
  );

  const stalled = [];
  page.on('response', async response => {
    const url  = response.url();
    const timing = response.timing();
    // timing.sendStart = ms from request start to first byte sent;
    // a high value on an image request signals scheduler stall.
    if (timing && timing.sendStart > 400 && /\.(avif|webp|jpe?g|png)$/.test(url)) {
      stalled.push({ url, stallMs: timing.sendStart.toFixed(0) });
    }
  });

  await page.goto('https://your-site.com', { waitUntil: 'networkidle0' });

  if (stalled.length) {
    console.error('Priority-stalled image requests detected:');
    console.table(stalled);
    process.exitCode = 1;   // fail the CI step
  } else {
    console.log('No stalled high-priority image requests.');
  }
  await browser.close();
})();

Warning: The Chrome DevTools Protocol does not expose a synchronous getNetworkRequests() call. You must attach event listeners before navigation — placing the page.on('response', …) handler after page.goto() will miss early requests.

3. CLI validation commands

# Generate a Lighthouse performance JSON baseline for LCP comparison
# --throttling-method=devtools matches the Fast 3G preset used in manual testing
lighthouse https://your-site.com \
  --only-categories=performance \
  --output=json \
  --throttling-method=devtools \
  --output-path=./lcp-baseline.json

# Extract every fetchpriority declaration from the rendered (post-JS) DOM.
# google-chrome --dump-dom runs the page through V8 before printing the DOM,
# so framework-injected hints are included in the output.
google-chrome \
  --headless \
  --disable-gpu \
  --dump-dom \
  https://your-site.com \
  | grep -i 'fetchpriority\|rel="preload"' \
  | sort | uniq -c | sort -rn
# Expected: exactly ONE fetchpriority="high" line, no duplicate preload hrefs.

# Inspect CDN response headers for cache or priority stripping.
# A CDN that strips Vary: Accept may serve the wrong image format;
# one that strips Cache-Control directives may break immutable caching.
# See /core-media-fundamentals-next-gen-formats/cache-control-headers-for-image-and-video-assets/
curl -sI https://your-site.com/hero.webp \
  | grep -i 'cache-control\|vary\|content-type\|priority'

Common mistakes & fixes

1. Pairing fetchpriority="high" with loading="lazy"

The two attributes are evaluated by different subsystems: loading gates whether the fetch is deferred at all, and only if the fetch is dispatched does fetchpriority influence its position in the queue. Because the deferral decision comes first, loading="lazy" always wins — see native lazy loading for images and iframes for the viewport-distance thresholds Blink applies before it releases a deferred image.

<!-- WRONG: the browser ignores fetchpriority on lazy-loaded images.
     The image will be deferred regardless of the priority declaration. -->
<img src="/hero.webp" fetchpriority="high" loading="lazy" alt="Hero" />

<!-- CORRECT: loading="eager" is required for the priority hint to take effect. -->
<img src="/hero.webp" fetchpriority="high" loading="eager" alt="Hero" />

2. Multiple fetchpriority="high" images on the same page

Tradeoff: Promoting two or more images to Highest priority does not serve them faster — it collapses priority differentiation and forces the H2 multiplexer to treat all of them equally, which effectively stalls each one while waiting for the others.

<!-- WRONG: two Highest-priority requests compete for the same H2 connection slots. -->
<img src="/hero.webp"    fetchpriority="high" loading="eager" alt="Hero" />
<img src="/feature.webp" fetchpriority="high" loading="eager" alt="Feature" />

<!-- CORRECT: one high, one auto (default). The scheduler will fetch the
     high-priority image first and the second in normal order. -->
<img src="/hero.webp"    fetchpriority="high" loading="eager" alt="Hero" />
<img src="/feature.webp"                      loading="eager" alt="Feature" />
<!-- WRONG: duplicate Highest-priority entries for the same byte range. -->
<link rel="preload" as="image" href="/hero.webp" />
<img src="/hero.webp" fetchpriority="high" loading="eager" alt="Hero" />

<!-- CORRECT: remove the preload link; fetchpriority communicates priority
     to the preload scanner without an extra DOM node. -->
<img src="/hero.webp" fetchpriority="high" loading="eager" alt="Hero" />

4. Framework hydration overriding native hints

Many meta-frameworks (Next.js, Nuxt, Astro) auto-inject <link rel="preload"> for above-the-fold assets during SSR. If fetchpriority="high" is already set on the <img>, this creates the duplicate-hint anti-pattern at runtime. The framework-side controls for this are documented under Next.js image component optimization; the equivalent switch in Astro lives in the Astro image and picture components reference.

// Next.js — disable automatic preload injection for non-LCP images.
// The priority prop sets fetchpriority="high" AND adds a preload link.
// Use it ONLY for the single LCP image; omit it for all others.

// WRONG: priority on two images
<Image src="/hero.webp"    priority={true} alt="Hero" />
<Image src="/feature.webp" priority={true} alt="Feature" />  // duplicate Highest

// CORRECT: priority only on the LCP image
<Image src="/hero.webp"    priority={true}  alt="Hero" />
<Image src="/feature.webp" priority={false} alt="Feature" />

5. CDN stripping the Priority HTTP request header

Browsers that support Priority Hints send a Priority: u=0, i (RFC 9218) request header alongside fetchpriority="high". Some CDN edge configurations strip unknown request headers before forwarding to origin, discarding the hint. The fix is CDN-specific: on Cloudflare, ensure no Transform Rules remove the Priority header; on AWS CloudFront, add Priority to the CachePolicy allowed headers list — the same policy surface covered in debugging CloudFront cache misses for images. Use curl -v and inspect the forwarded request headers in your origin access log to confirm the header arrives.

Tradeoff: widening the allowed-header list to carry Priority through to origin adds a cache dimension if the header is also included in the cache key. Allow it as a forwarded header only; never add it to the cache key, or every priority variant of the same image becomes a separate edge object.

Expected metric deltas after fix

Metric Target improvement Validation method
LCP load time –15 % to –35 % Lighthouse / WebPageTest
Stalled Highest-priority requests 0 DevTools Network → Priority column
Main-thread blocking (image decode) ~120 ms reduction DevTools Performance → Main thread
CLS score No regression (±0.01) Lighthouse / RUM dashboard
Image requests at Highest Exactly 1 --dump-dom grep count above
Total image transfer bytes Unchanged (±2 %) DevTools Network summary row
Time to first image byte –300 ms to –700 ms on Fast 3G Network → Timing → “Waiting (TTFB)”

Two measurement disciplines keep these numbers honest. First, a single Lighthouse run is not evidence: throttled LCP has a run-to-run standard deviation of roughly 8–12 % on the same build, so a 15 % improvement measured once is inside the noise band. Take the median of five runs before and after, and treat anything under 10 % as unproven. Second, total image bytes should not move. A priority fix reorders requests; it does not change what is downloaded. If bytes drop alongside LCP, you changed two things at once — most likely a duplicate request that was previously being fetched twice — and you should split the change so each effect is attributable.

Note also that the byte-total check is the fastest way to detect the URL-mismatch case described earlier: when the preload href and the <img> src differ by even a query string, the “before” measurement carries a full extra copy of the image, and removing the preload shows up as a large byte drop rather than as a pure latency win.