The fetchpriority Attribute & Priority Hints

Browsers guess. Every request a page issues is assigned a scheduler band by heuristic — resource type, position in the markup, whether layout has confirmed the element is in the viewport — and the heuristic is wrong often enough to matter. A hero image idles in the low band while an analytics script occupies a connection slot; a JSON payload that gates rendering queues behind six decorative thumbnails. The Priority Hints specification exists to correct exactly these misjudgements: it defines the fetchpriority content attribute for <img>, <link>, <script>, and <iframe>, plus a matching priority option on fetch(), letting the author shift a request’s computed priority up or down relative to what the heuristic would have chosen.

The operative word is shift. fetchpriority never pins a request to an absolute band, and it is advisory — a signal the browser weighs, not a command it must obey. Understanding what each value does per resource type, and what the attribute cannot do, is the difference between a hint that shaves 800 ms off your LCP and a sprinkle of attributes that changes nothing. This guide covers the spec surface, the per-type shift semantics, engine support, a step-by-step rollout, verification in DevTools and Lighthouse, and the edge cases where hints silently stop working.


What Priority Hints actually control

The browser’s scheduler places each request into one of five internal bands — Chromium labels them VeryHigh, High, Medium, Low, and VeryLow and surfaces them in DevTools as Highest through Lowest. The full band model is covered in the guide to browser resource priority queues; what matters here is the mechanism the Priority Hints spec adds on top of it.

Each resource type has a default computed priority, and fetchpriority moves the request one step relative to that default:

  • fetchpriority="high" — raise the request above its type default.
  • fetchpriority="low" — lower the request below its type default.
  • fetchpriority="auto" — the explicit default: let the heuristic decide. Identical to omitting the attribute.

Because the shift is relative, the same value produces different absolute outcomes on different elements. high on an async script lifts it from Low to High; high on a synchronous head script does nothing, because parser-blocking scripts already sit at the top of the range available to scripts. Conversely, low on an <iframe> is one of the most powerful demotions available, dropping the frame’s document request from High to Low and taking its entire subresource tree down with it.

The second thing the spec controls is invisible in markup: the priority option on fetch():

// Scheduling rationale: product data gates first render, so it must not
// queue behind image traffic — 'high' keeps it in the same band as
// parser-discovered critical requests.
const product = await fetch('/api/product/4211', { priority: 'high' });

// Scheduling rationale: telemetry has no render dependency; 'low' yields
// its bandwidth share to anything the user is waiting on.
fetch('/api/beacon', { method: 'POST', body: payload, priority: 'low' });

Programmatic fetches default to High in every engine — the browser assumes an explicit fetch() call is application-critical. On data-heavy pages that assumption is frequently backwards, which makes priority: 'low' on telemetry, prefetch warming, and speculative API calls one of the cheapest wins the spec offers.

What hints do not control

Three boundaries are worth internalizing before writing any attributes:

  1. Hints do not affect discovery. A request must exist before it can be reprioritized. An image referenced from a CSS background-image or injected by JavaScript after hydration is discovered late no matter what attribute you intend for it; discovery problems are solved by preload, not by Priority Hints.
  2. Hints do not cross the render-blocking ceiling. The document itself and render-blocking CSS occupy the top band unconditionally. No fetchpriority value places a subresource above them.
  3. Hints are advisory. The spec explicitly permits engines to ignore them. All shipping implementations honour them in the common cases, but the contract is “may influence”, not “must set” — build your loading strategy so it degrades gracefully when the hint is a no-op.

The diagram below shows the shift mechanics across the band ladder for the four most commonly hinted request types.

How fetchpriority shifts four request types between the browser's five priority bands A ladder of five priority bands labelled Highest down to Lowest. The document and render-blocking CSS sit fixed in the Highest band, out of reach of any hint. A below-the-fold image and an async script both default to Low and are lifted to High by fetchpriority=high. A fetch call and an iframe both default to High and are dropped to Low by the low value. Prefetch parks in the Lowest band and cannot be promoted. Where fetchpriority moves each request type — every shift is relative to the type default Highest High Medium Low Lowest document + CSS fixed, unhintable no fetchpriority value reaches this band img below fold default: Low img fetchpriority=high script async default: Low script async fetchpriority=high fetch() / iframe default: High fetch() / iframe priority = low prefetch parks here — no hint can promote it out promoted by high demoted by low heuristic default band Band names are Chromium DevTools labels; WebKit and Gecko apply equivalent shifts on their own internal scales.

How the hint reaches the network stack

It helps to know where in the pipeline the attribute is consumed, because that explains most of the failure modes later in this guide. In Chromium the sequence is:

  1. The preload scanner or the parser creates a candidate request. The scanner runs ahead of the main parser over raw bytes and already reads fetchpriority, which is why the attribute works on markup the parser has not formally reached.
  2. A priority is computed once, at request creation. The fetcher combines the resource type, the as value on a preload, whether the element is render-blocking, the scanner’s guess at viewport position, and the fetchpriority value into a single band. There is no later revision path from script: once the request exists, its band is fixed apart from the browser’s own in-viewport boost.
  3. The load scheduler decides whether the request may go out now. While render-blocking work is outstanding, Chromium runs in a restricted mode that allows only a couple of non-critical requests in flight at a time. Requests at Medium and above are exempt from that throttle. This is the part most people miss: promoting an image is not only about queue order, it is about escaping the throttle entirely.
  4. The band becomes a protocol signal. Chromium maps the computed priority onto the urgency value in the Priority request header and onto its HTTP/2 stream signalling, so the hint travels to the CDN as well as governing local ordering. Whether the edge acts on it is a separate question — see HTTP/2 priority trees versus the HTTP/3 Priority header.

Two consequences follow. First, a hint that arrives after step 2 is worthless, which is why the DOM-property ordering in Step 6 below matters so much. Second, the biggest single win from fetchpriority="high" on an image is often invisible in the Priority column: the request skips the non-critical throttle and is dispatched during the window when the browser is otherwise only fetching the stylesheet.


Default priority and shift behaviour by resource type

The table below is the working reference for Chromium’s computed priorities and how each fetchpriority value moves them. Treat the band names as Chromium’s DevTools labels; WebKit and Gecko implement equivalent shifts on their own internal scales.

Request type Default (auto) With fetchpriority="high" With fetchpriority="low"
Render-blocking CSS in <head> Highest Highest (no change) Highest (no change)
Synchronous <script> before content High High (no change) Low
<script async> / <script defer> Low High Low (no change)
<script type="module"> Low High Low (no change)
<img> outside viewport at parse time Low High Low (no change)
<img> confirmed in viewport by layout Medium → High (boosted) High from the start Low, no boost
<link rel="preload" as="image"> Low High Low (no change)
<link rel="preload" as="script"> High High (no change) Low
<link rel="preload" as="fetch"> High High (no change) Low
<link rel="modulepreload"> High High (no change) Low
<iframe> document High High (no change) Low (subtree inherits)
fetch() / XHR High High (no change) Low
<link rel="prefetch"> Lowest Lowest (hint ignored) Lowest

Three patterns fall out of this table. First, high is only meaningful on types whose default is Low — images, async and deferred scripts, image preloads. Second, low is only meaningful on types whose default is High — fetch(), iframes, script and fetch preloads. Third, the two ends of the range are immovable: render-blocking CSS cannot be demoted by a hint, and prefetch cannot be promoted out of the idle band (if a future-navigation resource turns out to be current-navigation critical, the correct fix is switching the hint type, not attaching fetchpriority).

One nuance deserves emphasis because it drives most real-world image wins: without a hint, an image that will end up in the viewport starts life at Low and is boosted only after layout confirms its position — typically hundreds of milliseconds into the load. fetchpriority="high" removes that round trip through layout entirely, granting the high band at discovery time. This early-versus-boosted distinction is invisible in a summary table but obvious in a network waterfall, where the hinted image’s request bar starts alongside the stylesheets instead of after first layout.

Before and after request timelines for one hero image on Fast 4G Two stacked timelines for the same page on Fast 4G. In the first, the document takes 320 ms and the stylesheet 280 ms, while the 1.4 MB hero image is discovered at 120 ms but sits queued at Low for 570 ms before transferring for 1,250 ms, so LCP lands at 2,050 ms. In the second, fetchpriority=high sends the hero at 150 ms with only 30 ms of queueing and a 1,030 ms transfer, so LCP lands at 1,290 ms — 760 ms earlier. Hero image, Fast 4G, 1.4 MB AVIF — the same page with and without the hint Before — no hint: the hero waits for the post-layout boost document.html 320 ms main.css 280 ms hero-1600.avif queued at Low 570 ms transfer 1,250 ms LCP element painted at 2,050 ms After — fetchpriority=high: the top image band is granted at discovery document.html 320 ms main.css 280 ms hero-1600.avif transfer 1,030 ms queue: 30 ms LCP element painted at 1,290 ms — 760 ms earlier ms since navigation start 0 500 1,000 1,500 2,000

Notice that the transfer itself also shortens, from 1,250 ms to 1,030 ms. That is not the band changing the link speed; it is the promoted request no longer sharing the connection with the six thumbnail requests that used to overlap it. On a multiplexed HTTP/2 or HTTP/3 connection, bandwidth is divided among active streams, so removing competitors from the same window is worth as much as starting earlier. It also explains why demotions elsewhere on the page improve the promoted request without anyone touching it.

Where the in-viewport boost helps and where it does not

Chromium’s boost is genuinely clever and covers a lot of pages without any author intervention: the first few images the layout confirms inside the viewport get lifted out of Low automatically. It fails in three recognisable situations.

  • The image is discovered after first layout. Anything injected by a carousel, a hydration pass, or a client-side router misses the window in which the boost would have mattered, because by then the boost and the hint would arrive at the same time.
  • The layout is expensive. On a page where the stylesheet is large and the first layout lands at 900 ms, the boost lands at 900 ms too. The hint lands at 120 ms.
  • The image is inside a container the layout has not sized yet. Web-component slots, content-visibility: auto regions, and late-measured aspect ratios can all delay the confirmation the boost depends on.

If none of those apply — a static hero, in the initial HTML, above a light stylesheet — the boost may already be doing the job, and the hint will look like it changed nothing. That is a correct outcome, not a failed rollout.


Browser support matrix

Feature Chrome / Edge (Chromium) Safari (WebKit) Firefox (Gecko)
fetchpriority on <img> 102 17.2 132
fetchpriority on <link> (preload) 101 17.2 132
fetchpriority on <script> 102 17.2 132
fetchpriority on <iframe> 102 17.2 Not implemented
fetch() priority option 101 17.2 132
fetchPriority IDL property reflection 102 17.2 132
Hint visible in DevTools priority column Yes (Priority column) Partial (priority not surfaced per-request) Yes (Priority column)

Engine differences worth planning around:

Behaviour Chromium WebKit Gecko
Image in-viewport boost without hint Low → boosted after layout Similar two-phase behaviour Images generally fetched eagerly at type default
high on async script Low → High Honoured Honoured; internal weight differs
low on <iframe> Frame + subtree demoted Honoured Ignored (attribute not implemented on iframe)
Effect on HTTP/2 / HTTP/3 stream priority signals Hint feeds urgency in the priority header and stream weights Maps to internal scheduling; weaker protocol coupling Maps to internal scheduling; weaker protocol coupling

The support floor — Safari 17.2 and Firefox 132 — is recent enough that a meaningful share of traffic still ignores the attribute. That is acceptable by design: an ignored fetchpriority leaves the heuristic default in place, so the attribute is safe to ship unconditionally. What you must not do is treat the hint as a functional dependency (for example, relying on priority: 'low' to keep a fetch from contending with your LCP on all browsers — on an engine that ignores it, contention returns).

There is a subtler cross-engine trap in the demotion direction. Because Gecko historically fetches images more eagerly than Chromium, a page tuned by adding low to twenty gallery images can behave differently there: the images Chromium was already deferring are the ones Firefox was fetching, and the attribute changes the shape of the load rather than confirming it. If your traffic has a meaningful Firefox or Safari share, read the cross-engine comparison in Chrome vs Safari vs Firefox priority differences before assuming a Chromium waterfall generalises.


Implementation, step by step

Before writing a single attribute, decide which lever the request actually needs. Most disappointing rollouts are cases where the request needed earlier discovery or outright deferral, and got a priority hint instead. The tree below is the order to work through.

Which loading lever does this request actually need? A four-question decision tree. If the URL is not in the HTML the parser reads, preload it first because a hint cannot fix discovery. If it is the LCP element or gates first paint, use fetchpriority high with a budget of one to three per page. If it is below the fold and rarely reached, use loading lazy. If its type already defaults to High, use fetchpriority low or the low priority option on fetch. Otherwise leave the request on auto. Which lever does this request need? Work down until a branch fires. Is the URL in the HTML the parser reads? Not injected by JS, not a CSS background. no Preload it first. fetchpriority cannot fix discovery. yes Is it the LCP element, or does it gate the first meaningful paint? yes fetchpriority="high" Budget: one to three per page. no Is it below the fold and rarely reached? Gallery tail, footer widgets, chat frames. yes loading="lazy" Deferral beats demotion here. no Does its type already default to High? fetch(), iframe, preload as=script. yes fetchpriority="low", or priority: 'low' on the fetch call. no Leave it on auto. The request already sits in the band a hint would ask for. Re-run the tree after every template change: hint creep dilutes the useful ones.

Step 1 — Baseline before touching anything

Open Chrome DevTools → Network, right-click the column header, and enable Priority. Apply Fast 4G throttling, hard-reload, and screenshot the first twenty rows. You are looking for two mismatches: critical resources sitting at Low or Medium, and non-critical resources sitting at High. Everything that follows targets one of those two lists. If you skip the baseline you cannot prove the hints did anything — verification depends on a before/after diff.

Record three numbers alongside the screenshot: the LCP value, the start time of the LCP resource, and the number of requests that were in flight when it started. The third number is the one that tells you whether demotions are worth writing; if only two requests overlap your hero, there is nothing to demote and the promotion is the whole story.

Step 2 — Elevate the LCP-driving image (one element only)

<!-- Scheduling rationale: the hero is the LCP element; high grants the
     top image band at discovery instead of waiting for the post-layout
     in-viewport boost, saving one layout round trip of queue time. -->
<img src="/img/hero-1600.avif"
     srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
     sizes="(max-width: 800px) 100vw, 1600px"
     width="1600" height="900"
     alt="Editor timeline with live collaboration cursors"
     fetchpriority="high" decoding="async">

The budget for high on images is effectively one per template. Note that decoding="async" is unrelated to the network band — it governs the main-thread decode step after the bytes land — but it belongs on the same element, because a promoted hero that then blocks the main thread for 40 ms of synchronous decode gives back part of what the hint won. The full diagnosis path for when this hint fails to move LCP — typos, late discovery, lazy-loading conflicts, CDN rewriting — is covered in fetchpriority=high not working on your LCP image.

One caveat on responsive images: the hint applies to the element, not to a candidate. Whichever URL srcset and sizes resolve to inherits the promotion, so a mistuned sizes that selects the 1600w file on a phone now downloads a too-large file faster and earlier, which is worse than the unhinted version. Verify the selected candidate at each breakpoint before shipping the attribute.

Step 3 — Demote high-band requests that are not render-critical

<!-- Scheduling rationale: the chat widget frame defaults to High and drags
     its whole subresource tree with it; low pushes the entire subtree
     behind first-party critical requests. -->
<iframe src="https://chat.example-widget.invalid/embed"
        title="Support chat" fetchpriority="low" loading="lazy"></iframe>

<!-- Scheduling rationale: A/B bucketing must run early (so async, not defer)
     but its bytes are small and non-render-critical — low keeps it from
     competing with the hero image for the first connection slots. -->
<script src="/js/experiments.js" async fetchpriority="low"></script>

Demotions are the underused half of the spec. Every request you move out of the high band returns bandwidth to the requests you left in it — often worth more than the single promotion in Step 2. The iframe case is the highest-leverage one on most commercial pages, because a third-party frame is not one request but a whole document load: its own HTML, its stylesheet, its fonts, its scripts, each inheriting a band from the frame’s own scheduler. Demoting the frame element demotes the tree.

Step 4 — Hint programmatic fetches

// Scheduling rationale: split data fetches by render dependency — the
// listing JSON blocks meaningful paint (high); recommendations render
// below the fold after idle (low). Both default to High without hints,
// which would make them compete head-to-head.
const listing = fetch('/api/listing?page=1', { priority: 'high' });
const recs    = fetch('/api/recommendations', { priority: 'low' });

const listingData = await (await listing).json(); // render path
renderListing(listingData);

(await recs).json().then(renderRecommendations); // fills in later

Because every fetch() defaults to High, a page issuing eight API calls at boot has eight requests fighting the LCP image. Classifying them explicitly is usually a one-line change per call site.

The same option is available on a Request object, which is the form to use when the call goes through a wrapper or a service worker:

// Scheduling rationale: constructing the Request carries the priority with
// the object, so any layer that forwards the Request (a retry wrapper, a
// service worker fetch handler) preserves the band instead of resetting it.
const req = new Request('/api/inventory', { priority: 'low' });
const res = await fetch(req);

Beacons are a special case worth calling out. navigator.sendBeacon() is already scheduled off the critical path and does not need a hint; a fetch() with keepalive: true does, because it is an ordinary High-priority fetch that happens to survive page unload. Analytics libraries that use keepalive fetches during the load — not just at unload — are a common hidden competitor for your hero image’s bandwidth.

Step 5 — Combine with preload where discovery is also late

<!-- Scheduling rationale: the poster is referenced from CSS, so the parser
     never sees it — preload fixes discovery, fetchpriority fixes the band.
     Preload as=image defaults to Low, so without the attribute this hint
     would fetch early but slowly. -->
<link rel="preload" as="image" href="/img/poster-1200.webp"
      type="image/webp" fetchpriority="high">

Remember the division of labour: preload moves the start of the request earlier; fetchpriority moves its queue position once started. Diagnose which of the two you are missing before applying both reflexively. A useful test: if the request’s start time is already close to the document’s, you have a priority problem; if it starts hundreds of milliseconds late, you have a discovery problem, and the hint alone will not help.

Step 6 — Reflect hints correctly from JavaScript

// Scheduling rationale: elements created after parse never met the preload
// scanner, so the band assignment happens at insertion — set the IDL
// property (camelCase) before assigning src, or the request is created
// with the default priority and the hint arrives too late.
const img = document.createElement('img');
img.fetchPriority = 'high';   // property is fetchPriority; attribute is fetchpriority
img.width = 1600;
img.height = 900;
img.alt = 'Checkout summary';
img.src = '/img/checkout-hero.avif'; // request is created here
document.querySelector('.hero-slot').replaceChildren(img);

The casing split trips up more teams than any other detail: in HTML the attribute is all-lowercase fetchpriority; in the DOM the reflected property is camelCase fetchPriority. Setting img.fetchpriority = 'high' creates an inert expando property and silently changes nothing.

The ordering rule is just as strict. Assigning src is what creates the request, so any priority you set afterwards is applied to nothing. The same applies to a dynamically inserted <link rel="preload">: set fetchPriority before appending the element to the head. Patterns for injecting hints safely at runtime are collected in dynamic hint injection via JavaScript.

Step 7 — Wire the hint through your framework, not around it

Most frameworks expose the hint under a different name, and hand-writing the attribute usually fights the abstraction rather than complementing it:

  • Next.jsnext/image takes a priority boolean that emits both a preload link and fetchpriority="high". Setting the raw attribute on a component that is also lazy by default produces the contradictory pair from the gotchas below; see fixing Next.js LCP image priority.
  • Nuxt<NuxtImg preload> and the head helpers generate the link; the attribute passes through as a normal prop.
  • Astro — the image component forwards unknown attributes, so fetchpriority="high" on the element works, but the priority shorthand also emits the preload.
  • SvelteKit — the enhanced image preprocessor keeps arbitrary attributes on the emitted <img>, and the data-loading directives are the place to classify fetches rather than the components.

Whatever the framework, the review rule is the same: exactly one element per route template may carry the promotion, and the code review that adds a second one should remove the first.


Verification workflow

DevTools Priority column

  1. Network panel → Priority column (right-click the header to enable it; enable Big request rows to see both initial and final priority when they differ). Hard-reload with throttling on.
  2. Confirm each hinted request landed in its intended band: the hero image at High from its first appearance, demoted iframes and fetches at Low.
  3. Hover the Waterfall bar of a demoted request. Expect its light-shaded queueing segment to lengthen — that is the demotion visibly yielding bandwidth. On an uncontended fast connection you may see no change at all; hints only manifest when requests actually compete.
  4. Check the Initiator column for duplicates: a preload whose URL fails to match its consumer produces two rows for the same asset, and the hint on the wrong row is wasted.

The panel below is what a correct rollout looks like on the worked example from Step 2 through Step 4.

The Priority column after the rollout, with three callouts A drawn Chrome DevTools Network panel with seven rows. The document and stylesheet are Highest, the hero image and the listing API are High, and the recommendations fetch, the experiments script and the chat iframe are Low. Three callouts to the right explain that the image hint grants the top band at discovery, that the recommendations fetch was demoted from its High default, and that demoting the iframe takes its whole subtree down with it. Network panel, Fast 4G, cold cache — Priority column enabled Name Type Priority Waterfall document.html doc Highest main.css css Highest hero-1600.avif img High /api/listing fetch High /api/recommendations fetch Low experiments.js script Low chat embed (iframe) doc Low High from the first row, not Low with a later boost. Demoted from the High that every fetch() gets by default. The frame's whole subtree inherits this demotion. The last three rows start later on purpose: their yielded bandwidth is what shortens rows three and four.

If your panel shows a hinted row with two priorities separated by an arrow, the browser changed its mind after the request was created — almost always the in-viewport boost firing on an image you did not hint. That is the signal to add the hint, because the boost is arriving late.

Lighthouse

Run a performance audit and open two sections. “Avoid chaining critical requests” (the big request chains view) shows whether your promoted resource still sits at the end of a long dependency chain — a hint cannot shorten a chain, only reorder siblings, so a deep chain here means the fix is architectural, not attribute-level. Second, the “Preload Largest Contentful Paint image” and prioritization diagnostics call out an LCP image that is discoverable but under-prioritized; after a correct rollout this audit should pass.

PerformanceObserver spot-check

// Verification rationale: renderBlockingStatus plus timing exposes whether
// the promoted request actually started early — a high hint on a
// late-discovered resource shows a large startTime despite the band change.
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.name.includes('hero')) {
      console.table({
        start: Math.round(e.startTime),
        queued: Math.round(e.requestStart - e.startTime),
        duration: Math.round(e.duration),
        transferSize: e.transferSize
      });
    }
  }
}).observe({ type: 'resource', buffered: true });

A successful promotion shows up as a small queued value and a start close to the stylesheet requests. A large start with a small queued value means your problem was discovery, not priority.

Confirming the win in the field

Lab numbers on a throttled profile prove the mechanism works; they do not prove it helps your users, because the effect size depends on how contended their connections actually are. Ship the same two measurements to your analytics endpoint for a fortnight:

// Verification rationale: pair the LCP time with the LCP resource's own
// start time, so a regression can be attributed to discovery, queueing or
// transfer rather than showing up as one opaque number.
new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  const res = performance.getEntriesByName(last.url)[0];
  navigator.sendBeacon('/rum', JSON.stringify({
    lcp: Math.round(last.startTime),
    lcpStart: res ? Math.round(res.startTime) : null,
    lcpQueue: res ? Math.round(res.requestStart - res.startTime) : null,
    protocol: res ? res.nextHopProtocol : null,
    cached: res ? res.transferSize === 0 : null
  }));
}).observe({ type: 'largest-contentful-paint', buffered: true });

Segment the result by cached before drawing conclusions. Warm-cache sessions will show no movement at all, and if they dominate your traffic they will dilute a real cold-load improvement into statistical noise. Segment by protocol too: the benefit of demotions is larger on a single multiplexed HTTP/2 or HTTP/3 connection, where all your streams genuinely share one bandwidth pool, than on a connection-limited HTTP/1.1 origin.


A worked example, end to end

A product listing page on a mid-range Android device over Fast 4G, before any hints:

Request Type Band Start Finish
document.html doc Highest 0 ms 320 ms
main.css css Highest 340 ms 620 ms
/api/listing fetch High 350 ms 910 ms
/api/recommendations fetch High 355 ms 1,180 ms
/api/beacon (keepalive) fetch High 360 ms 640 ms
hero-1600.avif img Low → High 690 ms 1,940 ms
6 × thumb-*.webp img Low 700 ms 2,010 ms
chat frame + subtree doc High 380 ms 1,760 ms
LCP 2,050 ms

Three problems are visible without any further tooling. The hero starts at 690 ms because it waits for the layout boost. Four High-band requests — two APIs, a beacon, and a third-party frame — are already occupying the connection when it finally starts. And the six thumbnails, though correctly at Low, still overlap the hero’s transfer window because nothing above them is finishing quickly.

Four attribute changes later — high on the hero, low on the recommendations fetch, low on the beacon fetch, low on the chat iframe — the same load looks like this:

Request Type Band Start Finish
document.html doc Highest 0 ms 320 ms
main.css css Highest 340 ms 620 ms
hero-1600.avif img High 150 ms 1,180 ms
/api/listing fetch High 350 ms 880 ms
/api/recommendations fetch Low 900 ms 1,620 ms
/api/beacon (keepalive) fetch Low 950 ms 1,240 ms
6 × thumb-*.webp img Low 1,000 ms 2,180 ms
chat frame + subtree doc Low 1,200 ms 2,600 ms
LCP 1,290 ms

The hero now starts at 150 ms, immediately after the preload scanner sees it, and finishes 760 ms earlier. Notice what got worse: the thumbnails finish 170 ms later and the chat frame 840 ms later. That is the trade being made explicitly, and it is the right one — nothing in that list is on the path to the metric the page is judged by. The failure mode to watch for is a demotion that is not actually deferrable, such as a fetch whose result the user is waiting on after an interaction; the queueing that shows up there is a real regression, and it appears in request queueing and stalled time rather than in LCP.


Edge cases and gotchas

fetchpriority and loading=lazy point in opposite directions

loading="lazy" withholds the request until the element approaches the viewport; fetchpriority reorders a request that exists. Combined on one element, laziness wins — there is nothing to prioritize until the deferred fetch is finally issued, at which point the hint applies to a request that is already catastrophically late. Never pair loading="lazy" with fetchpriority="high". The pairing with low is coherent, though largely redundant: a lazily-triggered request near the viewport is usually wanted soon, so demoting it can backfire. The mechanics of choosing between deferral and demotion are covered in deprioritizing below-the-fold images, and the thresholds each mechanism uses in lazy loading and viewport-driven fetching.

Preload + fetchpriority must agree with the consumer

When a <link rel="preload"> carries fetchpriority but the consuming element carries a different value, the request executes with the preload’s priority (it fired first) and the element’s hint is ignored for the network fetch. Keep the two declarations identical to avoid confusing future audits — and keep imagesrcset/imagesizes mirrored exactly, or the preload and the element fetch different URLs and you pay for both. The mismatch usually announces itself as a console warning; the patterns behind it are catalogued in debugging “preloaded but not used” warnings.

A service worker can silently reset the band

This is the most expensive gotcha on the list because it invalidates an entire rollout at once. When a fetch event handler responds with a new request built from the URL —

// BROKEN: rebuilding from the URL drops the priority the page asked for.
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request.url));
});

// CORRECT: forward the Request object, which carries priority, mode,
// credentials and destination with it.
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

— every hinted request on the page arrives at the network at the default priority. Your demotions vanish (everything is High again) and your promotion is lost. Audit any service worker that touches image or API traffic before concluding the attributes are not working.

Hints are advisory, and Save-Data can override them

Engines may discount hints under memory pressure, data-saver modes, or heuristics of their own. Chromium, for instance, reserves the right to cap speculative work when Save-Data: on is present. Treat fetchpriority as a statistical improvement across your traffic, not a guaranteed per-session behaviour — and never encode application logic (ordering assumptions, race expectations) against a hint being honoured.

Demoting a script does not defer its execution

fetchpriority="low" on an async script changes fetch scheduling only; the moment the bytes arrive, the script still parses and executes on the main thread at the same point it otherwise would. If the goal is protecting interactivity rather than bandwidth, you need defer, type="module", or an idle-time loader — the hint alone will not stop a 400 KB bundle from blocking the main thread the instant it lands. The execution-order rules that actually control that are covered in script loading: async, defer and execution order.

Early Hints and fetchpriority can disagree

A 103 Early Hints response can start a preload before the HTML exists. The band that request gets comes from the as value and any fetchpriority in the header — not from whatever the eventual HTML says. If the header preloads the hero as=image without a hint, it lands at Low; the matching <img fetchpriority="high"> later in the document then finds an in-flight request it cannot promote. Carry the hint in the header too, or the early start buys you less than it should.

Cache hits and back/forward navigations flatten the effect

A request served from the disk cache completes in a few milliseconds and never meaningfully contends, so reordering it is close to a no-op. A back/forward-cache restore replays no requests at all. Both are good outcomes, but they mean the population in which your hint matters is narrower than your total traffic: cold first views, on contended connections. Measure there, or the change will look like it did nothing.

High-band inflation

Inside a band, requests are dispatched in discovery order and share bandwidth. Promoting six resources to High recreates the original contention one band higher, with the added cost that genuinely critical requests now have more neighbours. Audit pages quarterly for hint creep: the value of every high decays with each additional one.

The attribute can be stripped before the browser sees it

HTML minifiers, template sanitizers, ad-server rewriters, and CDN image optimizers all rewrite <img> tags, and some drop attributes they do not recognise or replace the element entirely with their own markup. View the served HTML, not your template, when a hint appears to have no effect — curl the URL and grep for the attribute. If the optimizer owns the tag, apply the hint through the optimizer’s own configuration instead.


FAQ

Is fetchpriority the same thing as preload?

No. Preload changes when a resource is discovered; fetchpriority changes where an already-discovered request sits in the scheduler queue. Preload creates a request the parser has not reached yet, while fetchpriority reorders requests relative to each other. They solve different problems and are frequently combined on the same <link> element.

Does fetchpriority=low delay when the request is sent?

Not directly. The request is created at the normal discovery moment but enters a lower scheduler band, so it only receives bandwidth after higher-band requests are dispatched. Under low contention it may still start immediately; under contention it waits. This is different from loading="lazy", which withholds the request entirely until the element nears the viewport.

Why does the Priority column not change after I add the attribute?

The most common causes are a typo (fetchPriority is the DOM property; the HTML attribute is all-lowercase fetchpriority), placing the attribute on an element type the browser ignores it on, a hint that requests the band the resource already occupies, or an HTML-rewriting layer such as a CDN optimizer stripping unknown attributes before the browser sees them.

Can fetchpriority make a request jump ahead of render-blocking CSS?

No. Hints shift a request within the range available to its resource type; they do not outrank the top band reserved for the document and render-blocking stylesheets. An image with fetchpriority="high" competes with other high-band requests, not with the stylesheet that gates first paint.

How many fetchpriority=high hints should a page carry?

Treat one to three as the working budget. Every additional high hint dilutes the ones already present, because requests inside the same band are dispatched in discovery order and share bandwidth. A page where six images are all high behaves almost identically to a page with no hints at all.

Does a service worker preserve the hint?

Only if you preserve it. A handler that calls fetch(event.request) forwards the original Request object and the priority travels with it. A handler that rebuilds the request from event.request.url creates a brand new fetch at the default High priority, discarding both your promotion and every demotion on the page.

Does the hint help when the response comes from cache?

Barely. A disk cache hit costs a few milliseconds and does not contend for bandwidth, so reordering it changes almost nothing. Priority Hints pay off on cold loads and first visits — which is also where LCP problems concentrate — so evaluate them on cold-cache traffic rather than on your own warm repeat loads.

How does fetchpriority relate to the HTTP Priority header?

In Chromium the computed priority feeds the urgency value sent in the Priority request header and the equivalent HTTP/2 signalling, so a promoted request also asks the server to schedule its response earlier. Whether that has any effect depends on the CDN or origin honouring the header. Treat protocol-level scheduling as a bonus on top of local ordering, never as the mechanism you are relying on.

Should every below-the-fold image get fetchpriority=low?

No, because most of them already sit at Low and the attribute changes nothing. Demotion is worth writing only where the default is High: fetch() calls, iframes, script and fetch preloads, and images the layout has already boosted. Blanket low attributes across a gallery add markup and no scheduling change.

Can I change a request’s priority after it has started?

No. The band is computed when the request is created, and there is no API to revise it afterwards; assigning fetchPriority to an element whose src is already set does nothing to the in-flight fetch. The only way to reprioritize is to abort and re-issue, which means paying for the bytes twice — which is why the ordering in Step 6 matters.

Does fetchpriority work on <video>, <audio> or CSS background images?

Not today. The attribute is defined for <img>, <link>, <script> and <iframe> plus the fetch() option. Media elements take preload="none|metadata|auto" instead, which is a discovery control rather than a band control, and a CSS background image has no element to carry an attribute — preload it with as="image" and put the hint on the link.


Related