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.
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:
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:
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" />
3. Redundant <link rel="preload"> for an image already using fetchpriority="high"
<!-- 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.
Related
- Using fetchpriority to Optimize Critical Media — full reference for the
fetchpriorityattribute, priority levels, and LCP measurement - Preload vs Prefetch for Video and Image Assets — when
rel="preload"complements rather than conflicts withfetchpriority - Native Lazy Loading for Images and Iframes — why
loading="lazy"andfetchpriority="high"are mutually exclusive - Cache-Control Headers for Image and Video Assets — CDN caching pitfalls that interact with priority-hint delivery