WSS
Web Specification Studio Home
On this page
BlogPerformanceArchitecturePublished

Resource Hints: fetchpriority, modulepreload, and When preload Makes Pages Slower

fetchpriority, link rel preload, and link rel modulepreload each change different parts of the browser's loading pipeline. This covers what each one actually does, where it stops working, and the exact conditions that turn a preload into a performance liability.

This covers the mechanics behind fetchpriority, link rel preload, and link rel modulepreload. It explains what each one actually changes in the browser’s loading pipeline, where their effects stop, and the specific conditions under which a preload makes a page slower rather than faster.

The behavior described here follows the HTML Living Standard’s linked resource processing model and the Fetch Standard’s request priority mechanism, cross referenced against Chromium’s documented priority table, since that table is the most complete public description of how a mainstream engine actually schedules requests.

The three hints at a glance

HintWhat it changesWhat it does not change
fetchpriorityThe priority tier a resource is scheduled at, by one step up or down from its defaultDiscovery time. It has no effect on when the browser first learns a resource exists
link rel preloadDiscovery time. It tells the browser about a resource before the parser would normally find itPriority. The resource still fetches at whatever its default priority would be
link rel modulepreloadDiscovery time, and also parses, compiles, and registers the module in the document’s module mapNothing extra beyond preload for non-module resources. It only applies to module scripts

The rest of this article works through each of these in more detail, plus the specific ways they interact badly when combined without understanding what each one actually controls.

fetchpriority is a relative adjustment, not an absolute value

fetchpriority high and fetchpriority low do not set a resource’s priority to a fixed level. They shift it by one step from whatever its default would have been, and that shift is clamped by the resource type’s own ceiling and floor.

Chromium’s documentation describes the attribute as raising or lowering the default priority by an appropriate amount, rather than setting the priority to an explicit High or Low value. A resource whose default priority is already at the top of its available range does not move when given fetchpriority high. A resource given fetchpriority low does not necessarily drop to the bottom of the scale either.

ResourceDefault priorityEffect of fetchpriority highEffect of fetchpriority low
Early CSS (stylesheet link in head)HighestNo change, already at ceilingDrops one tier, to High
In-viewport imgHigh, after layout boostNo change, already at ceiling for imagesDrops one tier, to Low
async or defer scriptLowRises one tier, to HighNo change, already at floor
Preloaded fontHighNo change, already at ceiling for preloaded fontsDrops one tier, to Low

Two practical takeaways follow from this table:

  • Adding fetchpriority high to a render-blocking stylesheet in the head has no measurable effect, since that stylesheet is already scheduled at the highest tier the browser assigns to anything.
  • The attribute does useful work specifically on resources that start below their type’s ceiling. Most commonly that means images, which default to Low until layout determines they’re in the viewport, and async or deferred scripts, which default to Low regardless of position.

What preload actually is: a mandatory fetch at default priority

Preload does not change a resource’s priority. It changes when the browser becomes aware the resource exists.

Chromium’s Fetch Priority documentation draws this distinction directly, describing preload as a mandatory fetch rather than a hint, one that still fetches the resource at its default priority. A preloaded script tagged as script, with no explicit fetchpriority, gets whatever priority a script normally gets for its position and loading attributes. The preload only moves the moment of discovery earlier, typically to the point the HTML parser or the preload scanner reaches the link tag, rather than the point the resource would otherwise have been referenced.

This is why preloading an async script by itself does not make it load with more urgency once discovered. It still computes to Low priority as an async script. Getting both effects, earlier discovery and higher priority, requires combining the hint with fetchpriority explicitly.

<!-- Earlier discovery only. The script is still Low priority once found. -->
<link rel="preload" href="/js/critical-widget.js" as="script">
<script src="/js/critical-widget.js" async></script>

<!-- Earlier discovery and elevated priority -->
<script src="/js/critical-widget.js" async fetchpriority="high"></script>

The second form needs no preload at all. fetchpriority high on the script tag itself gets the priority boost without the extra link element, since the script is already discoverable at that position in the document. The preload plus script pattern only earns its place when the script would otherwise be discovered later, for example inside another script, where the parser can’t see it up front.

Preload does not bypass the layout-blocking phase

Chromium’s loader runs an initial phase that stays active until all render-blocking resources have finished. During this phase, Low priority resources are deliberately held back so bandwidth and connection slots go to resources the layout depends on.

A preload does not exempt a resource from this phase. Discovery time and dispatch time are two separate properties, and a preload only controls the first one:

  • Discovery time: when the browser first learns a resource exists. Preload moves this earlier.
  • Dispatch time: when the browser actually starts the network request. This is gated by priority tier, not by discovery time.

If a preloaded resource’s as type computes to Low priority (a decorative image, for instance), placing the link at the very top of the head only affects discovery. The request itself can still wait until the layout-blocking phase ends. For a resource that needs to start downloading immediately, not just be discovered immediately, pair the preload with fetchpriority high so it clears the Low priority hold-back:

<link rel="preload" href="/images/hero.avif" as="image" fetchpriority="high">

Without the fetchpriority attribute here, this preload still improves on not preloading at all, since the image is now known to the browser before the parser reaches the img tag. It does not, on its own, guarantee the request starts before the layout-blocking phase resolves.

Preload is a cost even when it works correctly

Every link rel preload is a network request the browser is obligated to make, regardless of whether anything ends up consuming the result. Three failure modes follow from that.

Unused preloads

If nothing on the page ends up using a preloaded resource within a few seconds of load, Chromium logs a console warning to that effect. This most commonly happens with:

  • A font only used by a print stylesheet.
  • A background image swapped out by a media query the preload doesn’t account for.
  • An A/B test branch that didn’t end up rendering.

In each case the bytes download and sit in the preload cache regardless, competing for bandwidth and connection slots with resources that are genuinely used.

Duplicate fetches from attribute mismatches

The browser matches a preloaded resource to its eventual consumer by URL, as value, and, for CORS-relevant resources, crossorigin mode. If any of these don’t match exactly between the preload link and the element that actually uses the resource, the match fails, and the browser issues a second, separate request for the same URL.

<!-- Mismatch. The stylesheet is fetched with default same-origin
     credentials, but the preload requested anonymous CORS mode.
     These do not share a cache entry. -->
<link rel="preload" href="https://fonts.example.com/font.woff2" as="font" crossorigin>
<link rel="stylesheet" href="https://fonts.example.com/styles.css">

If the stylesheet’s font-face rule references the same font URL without a matching CORS mode, the browser fetches the font twice: once for the preload, once for the actual style resolution. That doubles the transfer for that resource instead of saving time on it.

A few common versions of this mismatch, worth checking for directly:

  • Fonts without crossorigin. Fonts are always fetched in CORS mode regardless of origin, so a preload tagged as font but missing crossorigin will not match the eventual font fetch at all.
  • Responsive images without imagesrcset. If the actual img element uses srcset to pick a resolution by viewport width, the preload needs a matching imagesrcset, and imagesizes if sizes is used, or it downloads a variant the responsive image logic never requests, on top of whichever one actually gets used.
  • Type mismatches on preloaded scripts or styles. An as value that doesn’t match how the resource is actually consumed produces the same kind of miss.

Bandwidth contention from over-preloading

Each additional preload is one more mandatory request contending for the same connection pool and CPU time as the resources that actually gate rendering, during the same window the layout-blocking phase is trying to protect. A page with a dozen preload tags for resources of varying real urgency does not get a dozen resources loaded sooner for free. It spreads the available bandwidth across more concurrent requests, which can measurably delay the ones that matter most.

modulepreload is a different mechanism, not a module-flavored preload

Link rel modulepreload and link rel preload as script produce different outcomes for the same URL. The HTML Standard is explicit that this is deliberate rather than an alternate spelling of the same behavior: it describes modulepreload as a specialized alternative to preload, built with a processing model specific to module scripts, including a different interpretation of the crossorigin attribute, and it places the result into the document’s module map rather than the general preload cache.

link rel preloadlink rel modulepreload
Where the result is storedPreload cacheDocument’s module map
Request modeWhatever the consuming tag would normally useAlways CORS mode, regardless of origin
What crossorigin controlsWhether the request is cross-origin at allOnly the credentials mode within the CORS request (same-origin vs include)
Ready state after fetchBytes downloaded onlyFetched, parsed, compiled, and registered, ready for synchronous evaluation
Dependency discoveryNone. Only the linked URL is fetchedImplementation-defined. Some engines may also preload the module’s static imports, but this isn’t guaranteed

Two of these differences are worth walking through directly.

Always CORS mode. Because module script fetches always use CORS request mode, the crossorigin attribute doesn’t decide whether CORS applies. It only decides the credentials mode within that CORS request: omitted or set to anonymous sends same-origin credentials, use-credentials sends include credentials. A plain script src tag for the same URL with no crossorigin attribute uses a different, no-cors style credentialed request. Because the module map’s cache key includes these fetch options, a modulepreload link whose crossorigin value doesn’t exactly match the credentials mode the actual import will use produces a cache miss in the module map, and the module gets fetched a second time when it’s actually imported:

<!-- Mismatch. Preloaded with default same-origin credentials... -->
<link rel="modulepreload" href="/js/chart.js">

<script type="module">
  // ...but imported with explicit include credentials. Different
  // cache key, so this import triggers its own fetch instead of
  // reusing the preload.
  import('/js/chart.js');
</script>

Keeping the crossorigin attribute on the modulepreload link identical to whatever credentials mode the consuming import or script type module will use is what keeps this a single fetch instead of two.

Dependency graph preloading is not guaranteed. A module can statically import other modules, and an engine is permitted to walk those imports and preload them too when it processes a modulepreload link, but this is implementation defined, not a requirement. MDN’s documentation of the keyword calls automatic dependency preloading a browser-specific optimization, and says the only way to guarantee every browser preloads a module’s dependencies is to individually specify each one.

A build that emits a single modulepreload for an app’s entry chunk, and relies on the browser to discover and preload every module in the dependency graph, is relying on an implementation detail that isn’t portable across engines. The reliable version lists every module in the graph explicitly:

<link rel="modulepreload" href="/js/app.js">
<link rel="modulepreload" href="/js/chart.js">
<link rel="modulepreload" href="/js/format-utils.js">

Most bundlers that support modulepreload output generation, Vite is a common example, already do this. They emit one modulepreload tag per module in the graph rather than one for just the entry point, specifically because the alternative isn’t standardized behavior.

Import preloads have to come after the script that consumes them

The preload scanner walks the document in order as it’s received, discovering resources ahead of the main parser. For a modulepreload, or a plain preload standing in for a dynamic import, the purpose is usually to warm the cache for a dependency that a specific script will need. That link has to appear after the script tag that will consume it, not before.

<!-- Correct order. The parent script is discoverable and can start
     parsing and evaluating while its dependency downloads in the
     background. -->
<script type="module" src="/js/app.js"></script>
<link rel="modulepreload" href="/js/chart.js">

Reversing this order lets the preload scanner start fetching the dependency before the parent script has been fetched, parsed, or evaluated. That does not shorten the critical path: the dependency still has to wait for its parent to run before it can be used, and the parent’s own parse and eval now compete with a dependency fetch for the same connection and CPU resources instead of proceeding first.

fetchpriority’s effect at the network layer is inconsistent

Everything above describes the browser’s internal scheduling of when a request is dispatched from its own queue. What happens after the request leaves the browser is a separate question, and it isn’t uniform across protocols.

ProtocolHow priority reaches the networkWhat determines whether it’s honored
HTTP/1.xNot applicable in the same way. Requests are typically serialized per connection, so browser-side ordering matters mostConnection limits and request ordering at the browser level
HTTP/2Stream prioritization signals sent alongside the requestWhether the server or CDN’s HTTP/2 stack implements stream prioritization. Support is inconsistent across CDNs
HTTP/3The Priority request header defined by RFC 9218, sent by some browsers under some conditionsServer and CDN side support for acting on the header, which varies similarly to HTTP/2

MDN’s documentation of fetchpriority notes this directly, describing both the internal priority of any fetch operation and the impact of fetchpriority on that priority as entirely browser dependent. Testing a fetchpriority change against a single environment, a local dev server or a CDN that ignores priority signals entirely, can show no effect at all. The same change might measurably help in front of infrastructure that does honor the signal, or show only a browser-internal effect (request ordering within the browser’s own connection pool) with no corresponding change at the network level.

Resources the preload scanner cannot see

The preload scanner discovers resources by scanning raw markup as it streams in, looking for specific attributes it recognizes: src, href with a relevant rel, srcset, and similar. It does not evaluate CSS or run JavaScript. Resources it cannot see include:

  • A stylesheet whose media attribute doesn’t match the current environment, a print stylesheet loaded on a screen for example. This is invisible to the scanner regardless of any fetchpriority value on its link tag, because the scanner doesn’t determine media query applicability. That’s the main parser’s job, and the main parser reaches it only in document order, generally much later than resources the scanner picked up.
  • A background image referenced in CSS.
  • A font referenced through a font-face rule.
  • A URL built up or requested by JavaScript at runtime.

None of these are visible until the resource that contains the reference is itself fetched and processed. This is the specific case preload was introduced to solve: telling the browser about a resource the scanner has no way to discover on its own.

Diagnosing these behaviors in DevTools

What to checkWhereWhat it tells you
Priority columnNetwork panel, add via column header context menuThe computed priority for each request
Initial and final priorityNetwork panel, enable Big request rows in settingsWhether a fetchpriority hint took effect (starts and stays High) versus the browser’s own layout-based promotion (starts Low, boosted later)
Duplicate entries for the same URLNetwork panel waterfallA preload and consumer mismatch. Check as, crossorigin, and imagesrcset against the actual consuming element
Console warningsDevTools consoleA message reporting a resource preloaded but not used within a few seconds identifies an unused preload directly
Initiator columnNetwork panelFor a modulepreload being refetched, distinguishes the preload request from the subsequent import-triggered request, confirming a module map cache miss
Code coverageCoverage panelA preloaded script with low coverage on the loaded page is a candidate for narrowing what’s being preloaded

A console warning about an unused preload should be treated as a signal to remove the hint or fix the condition preventing its use, not to silence.

Lighthouse previously included a Preload key requests audit that flagged any resource three or more levels deep in a page’s critical request chain as a preload candidate, based purely on chain depth. That audit was removed as of Lighthouse 13. A depth-based heuristic like that one has no visibility into priority-tier contention or the layout-blocking phase: it can recommend preloading a resource that’s already reachable quickly through other means, or recommend enough simultaneous preloads to reproduce the bandwidth contention problem described above.

Chain depth is a reasonable starting signal for finding candidates worth investigating. Confirming an actual improvement still requires checking the resulting waterfall and priority values directly, rather than treating the recommendation as sufficient on its own.

From the team at

We build digital products and explore the modern web standards behind them.

Related posts