Mastering Link Rel Preload & Prefetch

Late-discovered critical resources are one of the most common causes of poor Largest Contentful Paint scores. The browser’s preload scanner finds <img>, <script>, and <link rel="stylesheet"> tags early in the HTML stream, but assets referenced inside CSS (url()), injected by JavaScript, or buried below parser-blocking scripts are invisible to it until it is too late. The <link rel="preload"> and <link rel="prefetch"> directives give you a direct channel into the browser’s fetch scheduler, letting you declare intent before the parser reaches the resource. Getting the attributes wrong, however, results in duplicate requests, cache misses, or priority inversion that is often harder to debug than the original problem.

This page covers the precise spec semantics for both directives, the browser engine differences that affect behaviour in practice, a step-by-step implementation guide with annotated code, a verification workflow using DevTools and the Resource Timing API, and the edge cases — CORS, MIME mismatch, preload scan limitations in SPAs — that catch most teams out.


How the fetch scheduler assigns priority

Before placing a hint, it helps to understand what you are actually influencing. The browser’s fetch priority system maps each resource to an internal priority band (Highest, High, Medium, Low, Lowest/Idle). The band determines queue position in the network stack, not just connection selection. Resources at the same priority level are dispatched in source order; a lower-priority resource behind a high-priority stall will wait even on an HTTP/2 connection where multiple streams are technically available.

preload inserts the target resource into the scheduler at a priority matching its as type — images get High, fonts and scripts get High or Highest depending on position, stylesheets get Highest. prefetch explicitly maps to Lowest/Idle regardless of as type, making it safe to issue without crowding critical resources.

The diagram below shows the state machine a <link rel="preload"> element passes through from HTML parse to cache fulfillment:

Preload lifecycle state machine: from link element parse through fetch queue insertion, network fetch, typed cache storage, and consumer fulfillment State diagram showing how a preload link element moves from HTML parse through attribute validation and priority queue insertion, out to the network fetch, into the typed cache keyed by URL plus destination, and finally into the consumer fetch that resolves from cache. A dashed branch leaves the validation state for an attribute error state that produces a console warning and a cache miss. What a <link rel="preload"> passes through, parse to fulfillment The typed cache is keyed by URL and destination — get the as value wrong and the consumer misses it HTML parser sees <link> Validate attributes as, type, crossorigin Priority queue band set by as value Network fetch High, or Idle for prefetch Typed cache keyed by URL + as Consumer fetch cache hit, 0 B transfer Attribute error console warning, then a miss as missing or wrong

Concept definition and browser engine differences

rel="preload"

preload is defined in the W3C Preload specification as a mandatory fetch — the browser must initiate the request when it encounters the hint, regardless of whether any script or parser has yet reached the consumer. Key properties:

  • Scope: current navigation only. The resource is not retained for future navigations beyond normal HTTP cache lifetime.
  • Priority: determined by the as attribute value, not by the hint type itself.
  • as is non-optional. Without it the browser fetches as a subresource at low priority, creates a mismatch in the typed cache, and the consumer triggers a second request.
  • crossorigin must match the destination request’s CORS mode. Fonts always require crossorigin; scripts and images only require it when fetched with credentials or from a different origin that enforces CORS.

rel="prefetch"

prefetch is a hint, not a mandate. The browser may ignore it under resource pressure. Key properties:

  • Scope: future navigation. The response is stored in the prefetch cache (separate from the HTTP cache) and promoted to the HTTP cache when consumed on the next page.
  • Priority: always Lowest/Idle.
  • as is optional but recommended to set the correct Accept header.
  • crossorigin follows the same rules as preload.

Choosing between them

In practice the choice is an ordered test, not a judgement call. Ask whether the asset is needed by the current navigation; if it is not, prefetch is the only correct answer and priority never enters the discussion. If it is needed now, ask whether it is an ES module — module graphs belong to modulepreload, which populates the module map rather than just the byte cache. Then ask whether the preload scanner can already see the asset in the raw HTML, because a hint for something the scanner has already found buys nothing and risks a duplicate fetch. Only then does the budget question apply: if the page already carries four to six high-priority preloads, adding a seventh moves bandwidth away from the assets that were already competing. The preload vs prefetch vs modulepreload decision matrix works through the same test asset type by asset type.

Decision tree with four ordered questions that select between prefetch, modulepreload, no hint, leaving the default priority, and preload Four question cards stacked in a column, each with an outcome card to its right. Question one asks whether the asset is needed during this navigation; answering no leads to prefetch at idle priority, the 180 kilobyte dashboard bundle case. Question two asks whether it is an ES module; yes leads to modulepreload. Question three asks whether the preload scanner already sees it; yes leads to no hint at all. Question four asks whether the page already carries four to six high-priority preloads; yes means stop and leave the default priority. Falling through all four questions reaches a final card recommending preload with a matching as and type value. Four questions, in order — the first yes ends the search Fall through all four and the asset has earned a preload; the budget question is the last gate 1 · Needed by this navigation? not a future route or interaction prefetch, as set, at idle priority the 180 KB /js/dashboard.bundle.js case no yes 2 · Loaded as an ES module? reached from a type=module graph modulepreload, not preload as=script parses it and fills the module map too yes no 3 · Scanner already sees it? plain tag in the served HTML No hint — the scanner found it already a hint here only risks a duplicate fetch yes no 4 · Already at 4–6 preloads? count the High band hints in head Stop — a 7th High starves the others leave default, or fetchpriority=low yes no preload, with as and type both matching the served resource crossorigin on every font, and on script or fetch whenever the consumer request uses CORS mode

Browser engine comparison

Behaviour Chromium (Blink) Safari (WebKit) Firefox (Gecko)
Preload with missing as Fetches at lowest priority, console warning, double-fetch on consume Same Same
Prefetch priority Idle (below Lowest) Idle Lowest
Prefetch cache persistence Survives page transitions within session Per navigation Per navigation
fetchpriority attribute on hint Supported (overrides default) Supported (Safari 17.2+) Supported (Firefox 132+)
Module preload (rel="modulepreload") Full support Full support Full support (FF 115+)
Preload for as="fetch" Supported Partial (no cache sharing with fetch()) Supported

Spec/API reference

The as attribute — value enumeration and priority mapping

The as value tells the browser which typed cache to store the response in and sets the fetch priority. An incorrect value causes a cache miss on consumption because the cache key includes the destination type.

as value Matches resource Default fetch priority Notes
style CSS stylesheets Highest Omitting causes render-blocking stall
script Classic and module scripts High Use modulepreload for ES modules instead
font Web font files High Always requires crossorigin
image Images (raster, SVG, WebP, AVIF) High (with fetchpriority="high") or Low Use fetchpriority="high" for LCP candidate
fetch fetch() / XHR responses High Requires crossorigin if cross-origin
document iframes High Rarely used directly
video Video sources Low Preload only the first segment
audio Audio sources Low
track WebVTT subtitle tracks Low
worker Web Workers High

Browser support matrix

Feature Chrome Edge Firefox Safari
rel="preload" 50 79 85 11.1
rel="prefetch" 8 12 2 12.1
rel="modulepreload" 66 79 115 17
fetchpriority on <link> 101 101 132 17.2
as="fetch" cache sharing 70 79 78 Partial

Step-by-step implementation

Step 1 — Audit the waterfall to identify late-discovered resources

Open Chrome DevTools, switch to the Network panel, reload the page, and sort by Start Time. Resources that appear after DOMContentLoaded (the blue vertical line) and carry a Render Blocking label or sit on the critical rendering path are candidates for preload. Resources consumed only on subsequent user actions or routes are candidates for prefetch.

Filter by Initiator column value Other or link to isolate already-hinted resources and confirm your existing hints are working. A hint that appears in the waterfall with its request starting before DOMContentLoaded but after the Parse HTML row indicates correct early scheduling.

Step 2 — Preload the LCP hero image with correct fetchpriority

Place the hint before any parser-blocking scripts or stylesheets in <head>. The fetchpriority="high" attribute overrides the browser’s default image priority, which would otherwise start at Low until the image is in the viewport:

<!-- Scheduling rationale: hero.avif is the LCP element — elevating to High priority
     ensures it competes with stylesheets in the fetch queue rather than waiting
     until the layout phase reveals it is in-viewport. -->
<link
  rel="preload"
  href="/assets/hero.avif"
  as="image"
  type="image/avif"
  fetchpriority="high"
  media="(min-width: 768px)"
>
<!-- Fallback for viewports below 768px where a smaller WebP is used -->
<link
  rel="preload"
  href="/assets/hero-mobile.webp"
  as="image"
  type="image/webp"
  fetchpriority="high"
  media="(max-width: 767px)"
>

The media attribute prevents unnecessary fetches on non-matching viewports. The browser evaluates it before dispatching the fetch, so the mobile hint is never sent to desktop users. Only one image on the page should get this treatment — see when to use preload vs prefetch for images for gallery, carousel and next-route artwork, and preloading critical above-the-fold assets for the wider set of first-viewport resources.

Step 3 — Preload web fonts with required crossorigin

Fonts are always fetched with CORS anonymous mode, even for same-origin font files, because the CSS specification requires it. Omitting crossorigin forces a second CORS-mode fetch when the font face rule is parsed, doubling the latency:

<!-- Scheduling rationale: the variable font covers all weights used in the critical
     viewport — preloading it at High priority prevents FOUT on the first paint.
     crossorigin is mandatory even for same-origin font files per the CSS Fonts spec. -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

Step 4 — Prefetch next-route assets during idle time

prefetch is appropriate for JavaScript bundles or images that will be needed only after a user interaction or navigation. Issue it after DOMContentLoaded or from a route transition hook so it does not compete with critical resources:

<!-- Scheduling rationale: the dashboard bundle is 180 KB and will be needed
     on /dashboard — prefetching at idle priority fills the cache while the
     current page is interactive, eliminating the round-trip on navigation. -->
<link rel="prefetch" href="/js/dashboard.bundle.js" as="script">

For SPA route transitions, inject hints programmatically at the right lifecycle point rather than hardcoding them in HTML:

// Scheduling rationale: inject prefetch only after the current route's
// critical resources have been consumed; requestIdleCallback ensures
// the hint fires during CPU/network idle gaps, not during rendering.
function prefetchRoute(path, asType) {
  if ('connection' in navigator && navigator.connection.effectiveType.includes('2g')) {
    return; // Suppress on metered / very slow connections
  }
  const existing = document.querySelector(`link[href="${path}"][rel="prefetch"]`);
  if (existing) return; // Idempotent — avoid duplicate hints

  const link = document.createElement('link');
  link.rel = 'prefetch';
  link.href = path;
  if (asType) link.as = asType;
  document.head.appendChild(link);
}

// Hook into router transition; the idle callback defers actual injection
router.afterEach((to) => {
  requestIdleCallback(() => prefetchRoute(`/js/${to.name}.bundle.js`, 'script'));
});

Step 5 — Pair preload with preconnect for third-party origins

A preload hint for a cross-origin resource cannot start the actual bytes until the TCP/TLS handshake to that origin completes. Add a preconnect hint above the preload to pipeline the connection setup and the resource fetch:

<!-- Scheduling rationale: preconnect establishes the TLS session to the CDN
     origin so that when the preload hint fires, the connection slot is ready —
     eliminating 100-300 ms of handshake latency from the critical path. -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link
  rel="preload"
  href="https://cdn.example.com/assets/hero.avif"
  as="image"
  fetchpriority="high"
>

The saving is easiest to see when the cross-origin preload is not the first hint in <head>. On a page carrying six preloads, the CDN hero image is typically dispatched fourth, around 260 ms in; without a preconnect above it the socket does not exist yet, so DNS, TCP and TLS all run after dispatch and the first byte of the image cannot arrive until roughly 550 ms. The preconnect does not make the handshake faster — it moves the same 290 ms of setup into the window where the parser is still working through the stylesheet:

Before and after timelines showing a cross-origin preload with and without a preconnect above it, moving a 290 millisecond handshake off the critical path Two stacked timelines on identical zero to nine hundred millisecond axes. In the before panel the stylesheet transfers from sixty to three hundred milliseconds, then the CDN hero image spends two hundred and ninety milliseconds on DNS, TCP and TLS from two hundred and sixty to five hundred and fifty, and only then transfers for two hundred and ninety milliseconds, finishing at eight hundred and forty. In the after panel a preconnect runs the same two hundred and ninety millisecond handshake from fifteen to three hundred and five milliseconds in parallel with parsing, so the image transfer runs from three hundred and five to five hundred and ninety-five and finishes two hundred and forty-five milliseconds earlier. preconnect does not shorten the handshake — it moves it off the critical path Fast 4G, cold cache, hero.avif at 118 KB on cdn.example.com, dispatched 4th of 6 preloads Before — preload alone, pointing at a cold third-party origin 0 200 400 600 800 900 ms app.css stylesheet 240 ms hero.avif (cdn) DNS + TCP + TLS 290 ms transfer 290 ms Cold socket: setup starts only when the hint is dispatched at 260 ms, so the image completes at 840 ms. After — preconnect placed above the preload in the same head 0 200 400 600 800 900 ms preconnect (cdn) handshake 290 ms, during parse hero.avif (cdn) transfer 290 ms Warm socket: the same 290 ms of setup ends at 305 ms, so the image completes 245 ms earlier, at 595 ms. bytes on the wire setup on the critical path setup off the critical path

Cap preconnect directives to four or fewer origins. Beyond that, the browser’s connection pool overhead starts consuming resources that would otherwise serve critical fetches.


Verification workflow

DevTools Network panel checks

  1. Open Chrome DevTools → Network. Reload with Disable cache unchecked (you want to test cache behaviour).
  2. Filter the waterfall to Initiator: link. The preloaded resources should appear at the very top of the waterfall, starting before or concurrent with the first CSS request.
  3. Check the Priority column: preloaded images should show High; fonts should show High; prefetched scripts should show Lowest.
  4. On a second (warm) load, the preloaded resources should show (disk cache) or (memory cache) in the Size column — a 0 B transfer confirms the cache hit.
  5. Look for any Unused Preload warning flags in the Console panel. These appear when a preloaded resource is not consumed within 3 seconds of the load event. The warning almost never means the hint was pointless — it usually means the preload map entry and the consumer request disagree on one field, and debugging “preloaded but not used” console warnings walks through comparing the two key by key.

PerformanceObserver verification snippet

The Resource Timing API exposes the initiatorType and priority for every resource. Use this snippet in DevTools Console to verify your hints fired with the correct priority:

// Verification: list all link-initiated resources with their priority and transfer size.
// A transferSize of 0 on the second run means the cache hit succeeded.
performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'link')
  .map(e => ({
    name: e.name.split('/').pop(),
    priority: e.priority,       // 'high', 'low', etc.
    transferSize: e.transferSize, // 0 = cache hit
    duration: Math.round(e.duration) + ' ms'
  }));

Run this after load on the first visit and then again on a hard-reload without cache clear to confirm prefetched assets return transferSize: 0.

Lighthouse audit

Run lighthouse --only-categories=performance and check for:

  • Preload key requests — Lighthouse surfaces late-discovered LCP candidates that should be preloaded.
  • Avoid unused preloads — fired when a preloaded resource is not consumed within the page lifetime.
  • Eliminate render-blocking resources — CSS or scripts that would benefit from preload or defer.

Edge cases and gotchas

CORS mismatch causes silent double-fetch

The single most common preload mistake is omitting crossorigin for resources that will be fetched with CORS mode. The browser maintains separate cache entries for no-cors fetches and cors fetches of the same URL. A preload without crossorigin caches a no-cors opaque response; when the font parser later requests the same URL with CORS mode, it gets a cache miss and initiates a fresh fetch. The network waterfall will show two requests to the same URL with different initiators.

Rule of thumb: fonts always need crossorigin. Scripts and fetch() requests need crossorigin when the destination fetch uses CORS mode.

MIME type mismatch blocks execution

If the as attribute value does not match the server’s Content-Type response header, Chromium logs a console error and refuses to execute the resource even if it was fetched successfully. A preload for as="script" that receives Content-Type: text/plain will fetch the bytes but the consumer script tag will throw a MIME type error. Always verify your server’s Content-Type header for each preloaded resource.

Preload scan limitations in SPAs

The browser’s speculative preload scanner only reads the initial HTML document. In a SPA where the initial <head> contains no <link rel="preload"> for route-specific assets, the scanner provides no benefit. Lazy-loaded route components discovered at runtime must be handled via dynamic hint injection via JavaScript or through the build tool’s preload manifest. Some frameworks (Next.js, Nuxt) generate <link rel="modulepreload"> tags server-side to recover speculative scanning for known routes.

rel="modulepreload" for ES module graphs

For ES modules loaded with <script type="module">, use rel="modulepreload" instead of rel="preload" as="script". Module preload fetches the module, parses it, and pre-populates the module map, which also enables the browser to speculatively fetch static imports listed in the module without waiting for parse completion. A plain as="script" preload fetches the bytes but does not parse the module graph, providing less benefit for module-heavy applications. Deciding which modules deserve a hint is a build-output question rather than a hand-authoring one; mapping Vite chunk graphs to modulepreload covers reading the manifest to generate the hint set.

Interaction with fetchpriority and HTTP/2 head-of-line pressure

fetchpriority="high" on a preload elevates the resource into the top of the fetch queue. On HTTP/2 multiplexed connections, this translates to a higher stream weight in PRIORITY frames. Issuing more than 4–6 fetchpriority="high" preloads simultaneously can starve other streams — including the HTML stream itself — if the server honours priority hints and the bandwidth is constrained. Cap high-priority preloads to the genuine critical set and leave secondary assets at default or fetchpriority="low".

no-store and private cache directives break prefetch

prefetch relies on the HTTP cache. If the target resource carries Cache-Control: no-store or a Vary header with values that differ between the prefetch request and the consumption request (for example, Vary: Cookie), the prefetched response cannot be reused and the consumer triggers a full network request. Validate Cache-Control headers on prefetch targets before relying on them for navigation speed improvements — Cache-Control headers vs resource hints sets out which header and hint combinations actually cooperate.


FAQ

The browser fetches the resource at a default (lowest) priority without placing it in the correct typed cache. When the parser or script later requests the same URL, it triggers a second network request because the first fetch was stored under the wrong cache key. You will also see a console warning in Chromium.

Does prefetch work across different origins?

Yes, but cross-origin prefetch responses are stored as opaque entries unless the server sends appropriate CORS headers and the hint includes crossorigin. Without CORS, the prefetched bytes are delivered but cannot be inspected by JavaScript, which prevents cache sharing for script and font resources that require CORS mode fetches.

Can I use preload and prefetch together for the same asset?

No — applying both directives to the same URL on the same page triggers two fetches: one high-priority (preload) and one idle-priority (prefetch). The prefetch fetch is redundant and wastes bandwidth. Use preload only on the current page.

How many preload hints are safe before degrading performance?

Cap high-priority preloads at 4–6 per page. Beyond that, early connection slots are saturated and other critical resources are starved. On HTTP/2 connections, excessive preloads increase head-of-line pressure within the multiplexed stream set despite not consuming separate TCP connections.

Will prefetch fire on slow or metered connections?

Browsers may suppress prefetch on slow connections at their discretion, but the spec does not mandate this. For reliable suppression on metered or 2G connections, check navigator.connection.effectiveType before injecting the hint and skip prefetch when the value is 'slow-2g' or '2g'.