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 fetch lifecycle state machine State diagram showing how a preload link element moves from HTML parse through priority queue insertion, network fetch, typed cache storage, and final consumer fulfillment — with an error path for as/MIME mismatches. HTML parser sees <link> Validate attrs rel, as, type, crossorigin Priority queue mapped by as type Network fetch (high/idle priority) Typed cache keyed by URL + as Attr error console warn + miss Consumer fetch cache hit → 0ms missing/wrong as

Concept definition and browser engine differences

rel="preload"

preload is defined in the 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.

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.

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"
>

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.

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.

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.


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'.