Resource Hint Implementation & Preloading Strategies

Modern web performance engineering requires proactive network orchestration rather than reactive asset fetching. This reference covers the full resource hint surface — preload, prefetch, preconnect, and dns-prefetch — from how the browser’s speculative fetch pipeline routes each request through its priority scheduler, to production implementation patterns for fonts, media streams, dynamic SPAs, and third-party origins. It is written for frontend engineers and performance teams who need to move beyond trial-and-error tuning and make deterministic decisions about what the network fetches, when, and at what priority.

Everything here is engine behaviour rather than folklore. Where Chromium, WebKit, and Gecko diverge, the divergence is named; where a number appears, it is a threshold you can reproduce in a trace of your own. The single most useful mental model to carry through the page: a resource hint never makes a byte travel faster. It only moves the moment the request is dispatched, and it does so by consuming bandwidth, sockets, and scheduler weight that something else on the page would otherwise have had.


How the Browser’s Speculative Fetch Pipeline Routes Hint Requests

Before the main parser constructs the DOM, Chromium’s preload scanner (a lightweight second-pass tokenizer) races ahead to extract <link>, <script>, and <img> references and dispatch them to the network scheduler. Resource hints slot into this pipeline at different points depending on their delivery mechanism and type.

The diagram below shows the end-to-end lifecycle from HTML receipt to cache storage:

Resource hint fetch pipeline from HTTP response through preload scanner, priority scheduler and network stack into cache, with the four hint types described below A four-stage horizontal flow — HTTP response, preload scanner, priority scheduler, network stack — with the HTTP cache hanging below the network stack. Beneath the flow, four labelled lanes summarise where preload, prefetch, preconnect and dns-prefetch enter the pipeline, what priority they receive and how long their effect lasts. How each hint type enters the browser's fetch pipeline HTTP response HTML bytes arrive Preload scanner speculative tokenizer Priority scheduler fetch priority queue Network stack DNS, TLS, transfer HTTP cache memory, then disk Cold load: an HTTP Link header preload reaches the scheduler 50-150 ms before the scanner. Without as=, Chromium files the fetch under Other, the lowest weight in the queue. Hint types preload Dispatched by the preload scanner before DOM parse. Highest or High. Needs as= or it is wasted. prefetch Idle-time fetch at Lowest priority. Lands in the prefetch cache, not the HTTP cache. preconnect Opens DNS, TCP and TLS early. No payload transferred. Socket held for about 10 s of idle. dns-prefetch Name resolution only. No TLS cost. The safe fallback for conditional third-party origins. Hints in an HTTP Link header are parsed before the preload scanner sees the HTML body.

The key scheduling insight: preload requests dispatched via an HTTP Link header arrive at the priority scheduler before the preload scanner has even begun tokenizing the HTML body. This matters for LCP-critical assets — a first-byte-timed Link: </hero.jpg>; rel=preload; as=image can save 100–300 ms on a cold load by eliminating the scanner’s discovery latency entirely.

The browser assigns a fetch priority tier to each resource hint based on the as attribute, the fetchpriority override, and the resource’s position relative to the viewport. Without the as attribute on a preload, Chromium falls back to Other priority — the hint is fetched but treated as the lowest-priority network request, defeating the purpose.

Where Each Delivery Mechanism Enters the Pipeline

There are five places a hint can be declared, and they differ by hundreds of milliseconds in dispatch time. Choosing between them is usually a bigger win than tuning the hint itself.

Mechanism Reaches the scheduler Cancellable Head start vs. <head> markup
103 Early Hints response before the final response headers No +100 to +200 ms
HTTP Link response header with the final response headers No +50 to +150 ms
<link> in <head> first preload-scanner pass Yes baseline
<link> in <body> scanner pass over that chunk Yes −50 to −200 ms
JS-injected <link> after the bundle parses and runs Yes −400 to −1500 ms

A 103 Early Hints response is the only mechanism that can dispatch a preload while the origin is still generating the HTML. On a route where the server spends 180 ms assembling the document, an interim 103 emitted at 20 ms hands the browser 160 ms of otherwise dead time in which to fetch the stylesheet and open the font-CDN connection. The cost is that the hint set must be safe for every variant of that response — an Early Hints payload cached against the wrong variant is a real failure mode, covered in avoiding Early Hints cache poisoning.

The bottom row of the table is the one most teams live with by accident. A hint injected by application JavaScript cannot fire until the bundle has been fetched, parsed, and executed, which on a mid-tier phone is routinely 800 ms to 1.5 s after the first byte. That is not a preload; it is a slightly early normal fetch. It is still the right tool for genuinely conditional assets, but it should never be where the LCP image’s hint lives.

Engine Differences That Change the Answer

The four hint types are specified in the Resource Hints and HTML standards, but coverage is uneven and the gaps matter for anything you ship.

  • Chromium 120+ implements all four hints, modulepreload, fetchpriority (since 101), and speculation rules. Its preload scanner runs on a background thread and re-runs after each network chunk, so hints late in a streamed document still fire before the parser reaches them.
  • WebKit / Safari 17+ implements preload, preconnect, dns-prefetch, and modulepreload, but has never shipped link rel=prefetch. Code that relies on prefetch for next-page warming silently does nothing on iOS — and because every iOS browser uses WebKit, that is the whole platform. fetchpriority landed in 17.2 and is honoured most consistently on images.
  • Gecko / Firefox 132+ implements all four hints and fetchpriority, but historically restricted prefetch to cacheable, same-origin responses and drops the entry when the response carries Cache-Control: no-store. Firefox also applies a stricter cap on concurrent speculative connections than Chromium.

The practical consequence is that a hint strategy must degrade gracefully. Treat preload and preconnect as universally available, dns-prefetch as a free no-op where unsupported, and prefetch as a Chromium-and-Gecko optimisation that a third of your traffic will never see. The engine-by-engine breakdown of the underlying scheduler is in Chrome vs Safari vs Firefox priority differences.


Spec Reference: Resource Hint Types, Priority Tiers, and Attribute Matrix

The table below is the canonical reference for production configuration decisions. Every cell reflects the Fetch Standard and the WHATWG HTML spec as implemented in Chromium 120+, Firefox 121+, and Safari 17+.

Hint type as required Default Chromium priority crossorigin semantics Scope Timeout / eviction
preload Yes Highest (script/style/font), High (image in viewport) Required for CORS resources; mismatch = double-fetch Current navigation Discarded if unused within ~3 s of load
prefetch Recommended Lowest Optional; affects CORS mode Next navigation Stored in prefetch cache for 5 min
preconnect No N/A (connection, no payload) crossorigin opens a CORS-anonymous socket (needed for font CDNs) Current navigation Socket recycled after ~10 s idle
dns-prefetch No N/A (DNS only) No effect Current navigation OS DNS TTL
modulepreload No (as="script" implied) High Always CORS; sets credentials: same-origin Current navigation Same as preload for scripts

fetchpriority override values (HTML spec, §fetch-priority):

Value Effect on Chromium’s net priority
high Promotes to Highest for images; maintains Highest for scripts
low Demotes LCP image candidate to Low; useful for below-fold images
auto (default) Browser heuristic based on as type and position

Browser support matrix for fetchpriority:

Browser Version added Notes
Chrome / Edge 101 Full support including HTTP header equivalent
Firefox 132 Shipped behind a flag until 132
Safari 17.2 Partial — honoured on images, not yet on scripts in all cases

Two clauses in that first table are worth reading twice. as is not merely a priority hint: it sets the request’s destination, which in turn selects the CORS mode, the Accept header, the Content Security Policy directive that applies, and the cache partition the response lands in. A preload declared as="fetch" for something the page consumes as an image will fetch successfully, warm nothing, and download the bytes twice. And the crossorigin column is a matching rule, not a security setting — the preload cache key includes the credentials mode, so the hint and the consuming element must agree exactly or the entry never matches.


The Preload Cache and the Prefetch Cache Are Different Stores

Almost every confusing hint bug traces back to one fact: preload and prefetch populate two entirely separate stores with different keys, different lifetimes, and different eviction rules. Neither of them is the HTTP cache.

A preload response lands in an in-memory preload cache scoped to the current document. It is keyed by URL plus destination plus credentials mode, it is checked before the HTTP cache when a matching request appears, and it is torn down when the document goes away. Roughly three seconds after the load event, Chromium sweeps entries nothing has claimed and logs the familiar console warning. A prefetch response instead lands in a prefetch cache scoped to the browsing profile, keyed additionally by the top-level site, with a five-minute time-to-live and no requirement that anything on the current page consume it.

State machine comparing the lifecycle of a preload cache entry with the lifecycle of a prefetch cache entry Two parallel state tracks. The upper track follows a preload from hint parsing through a Highest-priority fetch into the preload cache, then either to consumption by a matching element or to a three-second unused sweep with a console warning. The lower track follows a prefetch through an idle-time Lowest-priority fetch into the origin-partitioned prefetch cache, then either to reuse on the next navigation or to expiry after five minutes. Two speculative stores, two eviction clocks rel=preload — memory entry scoped to this document, swept ~3 s after load Hint parsed in the head Fetch at Highest as= sets the tier Preload cache entry live Consumed by the matching element Unused ~3 s: warn and discard no matching request rel=prefetch — profile entry partitioned by top-level site, 5 minute TTL Hint parsed rel=prefetch Idle-time fetch Lowest priority Prefetch cache 5 min TTL Next navigation serves the entry Expired or cross- site: full refetch no navigation in time Neither store survives a cross-site navigation: both are partitioned by top-level site.

Two consequences fall straight out of that diagram. First, the three-second sweep is why a preload for an asset behind a slow API response is worse than no preload at all: the bytes arrive, sit unclaimed, get discarded, and are then fetched again when the UI finally renders. Second, the five-minute prefetch TTL is why prefetching on page load is usually wasted on content sites — median dwell time on an article exceeds five minutes often enough that the entry has evicted by the time the reader clicks. Tie the prefetch to an intent signal instead, and read debugging “preloaded but not used” console warnings before you start deleting hints at random.


Critical Path Analysis: What Blocks Render vs. What Is Safe to Defer

Not all resource hints are created equal in terms of render-blocking impact. The critical rendering path stalls when the browser cannot paint the first frame — typically because a stylesheet, a parser-blocking script, or a font referenced in CSS is still in flight.

Render-blocking by hint type:

  • <link rel="preload" as="style"> — the preloaded stylesheet becomes render-blocking the moment it is consumed by a <link rel="stylesheet">. If the hint fires but the consuming tag is late in the HTML, you gain nothing.
  • <link rel="preload" as="font"> — fonts do not block render directly, but a missing font causes FOIT (Flash of Invisible Text) or layout shift. Pair with font-display: swap to control the fallback window.
  • <link rel="preload" as="script"> — the preloaded script does not execute until its <script> tag is parsed. It only accelerates the fetch, not execution order.
  • <link rel="prefetch"> — never render-blocking; idle-priority; safe to use freely for next-page navigation assets.
  • <link rel="preconnect"> — never render-blocking, but a failed or late preconnect can delay the first critical request by the full DNS + TLS handshake round-trip (typically 200–600 ms on mobile).

Concrete thresholds for decision-making:

  1. Preload any asset that appears in the LCP candidate (above-the-fold image, hero font) and is not already discoverable in the first 14 KB of the HTML response.
  2. Add preconnect only to origins you will fetch from within the first 2 s of page load — Chromium closes idle sockets after ~10 s, so speculative preconnects for deferred content waste socket pool slots.
  3. Use dns-prefetch (not preconnect) for analytics, A/B testing, and consent-management endpoints that load conditionally or after user interaction.
  4. prefetch is appropriate when the probability of the user navigating to the next page exceeds ~50% — e.g., a single-product checkout funnel where step 2 always follows step 1.
  5. Cap total preconnect hints at 3–4 origins. HTTP/2 multiplexing does not eliminate socket pool limits; each origin still consumes a connection.

Those five thresholds collapse into a single decision procedure. Run every candidate URL through it before adding a tag:

Decision tree choosing between preload, preconnect, prefetch and dns-prefetch from the two-second, payload and fifty-percent thresholds A two-level binary decision tree. The root asks whether the resource is needed within two seconds of the current navigation. The yes branch splits on whether the need is for payload bytes or only for a third-party origin, producing preload and preconnect. The no branch splits on whether the next navigation is more than fifty percent likely, producing prefetch or speculation rules and dns-prefetch. Choosing a hint: three questions and four answers Needed within 2 s of this navigation? yes no Payload bytes, or just a third-party origin? Next navigation more than 50% likely? payload origin likely unlikely preload with as= plus fetchpriority=high when it is the LCP preconnect cap the page at 3-4 origins, then stop prefetch or speculation rules, at Lowest; time it to hover or focus dns-prefetch only resolution cost only, no socket held open Skip the hint entirely when the URL already sits in the first 14 KB of HTML: the scanner finds it in the same round trip. Every branch assumes the consuming element mirrors the hint's crossorigin mode exactly.

The tree deliberately has no branch for “preload it just in case”. Speculative preloading is not free: each hint adds a request that competes for the same congestion window as the stylesheet and the LCP image, and on a 4G connection with a 1.5 Mbps effective throughput, three unnecessary 90 KB preloads delay the genuinely critical bytes by roughly 1.4 s. When the answer to the root question is “maybe”, the correct action is to defer the decision to runtime rather than to guess in markup.

For a deep-dive into which CSS configurations are render-blocking and how to identify them, see render-blocking resource identification. For the reverse problem — resources the browser should deliberately fetch late — see lazy loading and viewport-driven fetching, which is the natural counterpart to everything on this page.

A Worked Example: 3.90 s LCP Down to 2.05 s

The decision procedure is easier to trust once you have watched it applied to a real waterfall. The page below is a marketing landing page on a mid-tier Android device throttled to 4G — 1.6 Mbps down, 150 ms RTT. The LCP element is a hero image; the hero heading uses a self-hosted variable font; the page pulls a consent banner and an analytics tag from two third-party origins.

The starting trace, in dispatch order:

# Resource Dispatched at Why then Priority
1 document 0 ms navigation Highest
2 app.css 190 ms scanner, in <head> Highest
3 consent.js 195 ms scanner, in <head> High
4 inter-var.woff2 640 ms CSSOM built, @font-face applied Highest
5 hero-1600.webp 700 ms parser reached <img> below the fold-line markup High
6 analytics.js 980 ms injected by consent.js after user check Low

LCP landed at 3.90 s. Three separate problems are visible, and only one of them is fixed by a preload.

Problem one — the hero is discovered 700 ms in. The <img> sits after 41 KB of above-the-fold markup, well past the first flight, so the scanner never sees it in the first round trip. This is the textbook case for an imagesrcset-bearing preload in the <head>, which moves dispatch from 700 ms to 195 ms.

Problem two — the font waits for the CSSOM. At 640 ms the font request has already lost two round trips to CSS parsing. A crossorigin preload moves it alongside the stylesheet, and because the font is now in flight during CSS parse rather than after it, the swap happens before first paint rather than 400 ms into it.

Problem three — the consent origin costs a cold handshake. consent.js spends 310 ms in DNS + Connect + SSL before a byte moves. A preconnect in the document head pays that during the HTML download instead, but only if it is one of at most three or four — and the analytics origin, which is not needed until 980 ms and only conditionally, gets dns-prefetch instead of a fourth socket.

The changed head, in full:

<link rel="preconnect" href="https://consent.example-cdn.net" crossorigin>
<link rel="dns-prefetch" href="https://metrics.example-cdn.net">
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-var-latin.woff2" crossorigin="anonymous">
<link rel="preload" as="image" fetchpriority="high"
      href="/assets/hero-1200.webp"
      imagesrcset="/assets/hero-640.webp 640w,
                   /assets/hero-1200.webp 1200w,
                   /assets/hero-1600.webp 1600w"
      imagesizes="(max-width: 768px) 100vw, 60vw">

The resulting trace:

# Resource Dispatched at Change Priority
1 document 0 ms Highest
2 app.css 190 ms Highest
3 hero-640.webp 195 ms −505 ms, and 640w not 1600w Highest
4 inter-var-latin.woff2 198 ms −442 ms Highest
5 consent.js 205 ms −300 ms of handshake High
6 analytics.js 950 ms DNS already resolved Low

LCP: 2.05 s. Note where the gain actually came from. The preload moved the dispatch by 505 ms, but the larger share came from imagesrcset selecting the 640w candidate — 71 KB instead of 244 KB, which on a 1.6 Mbps link is 865 ms of transfer time removed. A preload that fetches the wrong file is a slower page with a better-looking waterfall.

Third-Party Origins: Ordering the Warm-Up

Third-party origins are where the connection budget is spent and where it is most often overspent. The rule that survives contact with production is a strict ranking rather than a per-origin judgement call:

  1. The origin serving an LCP-critical byte gets preconnect with crossorigin if it serves fonts, without if it serves images. It is always first.
  2. The origin serving a render-blocking script or stylesheet gets the second preconnect, if there is one you cannot remove.
  3. An origin fetched within the first 2 s but not on the critical path gets the third preconnect — and only if the page has fewer than four in total.
  4. Everything else gets dns-prefetch, which costs a UDP round trip and holds nothing open.

Two subtleties bite here. A preconnect without crossorigin opens a socket in the wrong CORS mode for a font fetch, so the font request opens a second connection and the hint has cost you a handshake instead of saving one; when in doubt for a font CDN, emit both forms. And a redirecting origin invalidates the hint entirely: preconnecting to a host that immediately 301s to a different host warms a connection nothing will use. Follow every third-party URL to its final host before writing the hint, and re-check after every vendor change.

Origin-level accounting — how much paint delay each vendor actually owns — is covered in third-party resource impact mapping, and automating the hint set from a live inventory in automating preconnect for third-party APIs.


Implementation Patterns

1. HTML Tag Preload with fetchpriority (Preferred for LCP Assets)

<!-- Preload the LCP hero image at Highest priority.
     fetchpriority="high" overrides the default heuristic so the scheduler
     does not downgrade this to High when it detects many concurrent image requests. -->
<link rel="preload"
      href="/assets/hero.webp"
      as="image"
      type="image/webp"
      fetchpriority="high">

<!-- Preload a critical web font.
     crossorigin="anonymous" is required even for same-origin fonts because
     the font fetch uses a CORS request internally; omitting it triggers a
     second uncached fetch when the font is actually consumed. -->
<link rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin="anonymous">
# Sent by the origin server or CDN edge before the HTML body is transmitted.
# The browser processes this before the preload scanner sees a single byte of HTML,
# giving the critical bundle a ~50-150 ms head start on a typical TTFB.
Link: </assets/critical.css>; rel=preload; as=style
Link: </assets/lcp-hero.webp>; rel=preload; as=image; fetchpriority=high

The trade-off: HTTP header preloads are unconditional. Unlike <link media="(max-width: 768px)">, a header preload fires on every request regardless of viewport, consuming bandwidth even when the asset is not used. Scope HTTP header preloads to assets that are always needed on that route.

3. Dynamic Preload Injection for SPA Route Transitions

/**
 * Inject preload hints when a route change is detected.
 * Called before the route's chunk is actually requested so the browser
 * can start fetching in parallel with the JavaScript route resolution.
 *
 * Scheduling rationale: inserting into <head> immediately (not deferred)
 * ensures the hint reaches the network scheduler before the dynamic
 * import() call dispatches its own fetch — preventing a staggered waterfall.
 */
function preloadRouteChunks(chunks) {
  const frag = document.createDocumentFragment();

  chunks.forEach(({ href, as, type, crossOrigin }) => {
    // Avoid duplicate hints: check if a preload for this href already exists
    if (document.querySelector(`link[rel="preload"][href="${href}"]`)) return;

    const link = document.createElement('link');
    link.rel = 'preload';
    link.href = href;
    link.as = as;
    link.fetchPriority = 'high';
    if (type) link.type = type;
    // Mirror crossorigin on the hint to match the consuming element's CORS mode
    if (crossOrigin) link.crossOrigin = crossOrigin;
    frag.appendChild(link);
  });

  document.head.appendChild(frag);
}

// Wire into your router — example using a generic navigation event
window.addEventListener('navigate', (e) => {
  preloadRouteChunks(getChunksForRoute(e.destination.url));
});

For a full treatment of SPA-specific patterns and modulepreload chaining, see dynamic hint injection via JavaScript and, for the router-level failure this pattern exists to fix, fixing preload scanner misses in single-page apps.

4. Viewport-Scoped and Responsive Preloads

An unconditional preload for a desktop hero image is a pure regression on mobile, where a different source is actually rendered. Two attributes solve this, and both are frequently forgotten.

<!-- media= makes the hint conditional. The browser evaluates the query before
     dispatching, so exactly one of these two requests is ever made. -->
<link rel="preload" as="image" media="(max-width: 768px)"
      href="/assets/hero-640.webp" fetchpriority="high">
<link rel="preload" as="image" media="(min-width: 769px)"
      href="/assets/hero-1600.webp" fetchpriority="high">

<!-- For a responsive <img srcset>, mirror the candidate list on the hint.
     imagesrcset and imagesizes make the preload pick the same candidate the
     <img> will pick; without them the hint fetches href and the <img> fetches
     a different file, doubling the bytes. -->
<link rel="preload" as="image"
      href="/assets/hero-1200.webp"
      imagesrcset="/assets/hero-640.webp 640w,
                   /assets/hero-1200.webp 1200w,
                   /assets/hero-1600.webp 1600w"
      imagesizes="(max-width: 768px) 100vw, 60vw"
      fetchpriority="high">

The imagesrcset form is the single highest-value correction available on most image-heavy pages. A responsive hero preloaded without it typically wastes between 40 KB and 180 KB per load and, worse, leaves the LCP element waiting on a request that started at the same time as everything else.

5. modulepreload for ES Module Graphs

preload as="script" fetches bytes. modulepreload fetches bytes, applies module CORS semantics, compiles the module, and walks its static import graph so that dependencies are discovered without waiting for the parent to execute. On a three-deep import chain over a 150 ms RTT link, that is the difference between three sequential round trips (roughly 450 ms) and one.

<!-- The entry module and its two hot dependencies, declared up front.
     Order is a hint, not a guarantee: the browser fetches all three in parallel. -->
<link rel="modulepreload" href="/js/app.entry.js">
<link rel="modulepreload" href="/js/vendor.router.js">
<link rel="modulepreload" href="/js/vendor.store.js">

The entry point itself is then loaded as usual with <script type="module" src="/js/app.entry.js">; by the time the parser reaches it, all three modules are already fetched and compiled.

Generating that list by hand does not survive a bundler upgrade. Derive it from the build manifest instead — the mechanics are in mapping Vite chunk graphs to modulepreload, and the waterfall this removes is dissected in fixing dynamic import request waterfalls.


Font Loading: Preload, CORS, and FOUT Prevention

Web fonts occupy a uniquely awkward position in the resource priority system: they are discovered late (CSS must be parsed first), they require CORS even for same-origin fetches in some contexts, and the browser’s default rendering strategy (FOIT — block text paint for up to 3 s) is the worst possible outcome for Cumulative Layout Shift and perceived performance.

The correct configuration is a three-part coordination:

<!-- Step 1: Preload the font file early — before CSS parsing has even started.
     type="font/woff2" is required; without it the browser may fetch the file
     but reject it when the CSSOM tries to apply it (MIME mismatch). -->
<link rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin="anonymous">
/* Step 2: Declare the font face with font-display: swap.
   'swap' gives the font a zero-second block period and an infinite swap period,
   so text renders immediately in a fallback font and swaps in when Inter loads.
   Use 'optional' instead if layout shift is more harmful than FOUT for your users. */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}
/* Step 3 (optional but recommended): Observe font load completion
   to trigger any layout-sensitive UI updates atomically.
   This avoids a second layout shift from JavaScript reading stale metrics
   before the font swap has occurred. */
document.fonts.ready.then(() => {
  document.documentElement.classList.add('fonts-loaded');
});

Three refinements separate a competent font setup from a fast one.

Preload exactly one file per rendered face. A variable font preloaded once covers every weight the page uses; preloading four static weights when the design only paints two of them above the fold spends 200 KB to save nothing. Count the faces that actually paint in the first viewport and preload precisely those.

Subset aggressively and split by unicode-range. A full Latin-Extended-plus-Cyrillic Inter build is around 312 KB; the Latin-only subset a typical English page renders is closer to 68 KB. Because unicode-range lets the browser skip downloading a subset no glyph on the page needs, the split costs nothing when it is wrong and saves a quarter of a megabyte when it is right. The preload hint, though, has no unicode-range — it always fetches. Only preload the subset the first viewport is certain to need. The full workflow is in subsetting and preloading variable fonts.

Neutralise the swap with metric overrides. size-adjust, ascent-override, and descent-override on a fallback @font-face let you match the fallback’s line box to the real font’s, so the swap changes glyph shapes without moving a single line of text. This is what turns font-display: swap from a CLS liability into a free win.

For the full treatment of FOUT/FOIT trade-offs and variable font strategies, see font loading optimization & FOUT prevention.


Preloading Media and Streaming Sources

Media is where naive preloading does the most damage, because the numbers are two orders of magnitude larger than for scripts and fonts. A single 38-second 1080p MP4 is comfortably 4.2 MB; a preload="auto" on it will consume the entire congestion window that the LCP image needed, and on a metered connection it will do so for a video most visitors never play.

The <video> element’s own preload attribute is the primary control, and link rel="preload" is a poor substitute for it: as="video" fetches whatever byte range the server offers rather than the small header range a player actually needs to become ready. The table below compares the four realistic configurations on the same asset.

Comparison matrix of four media preload configurations by bytes fetched before playback, time to first frame, and bytes wasted when the user never plays A four-row matrix comparing preload=none, preload=metadata, preload=auto and a manifest-plus-first-segment strategy for the same 4.2 MB video. Columns give the bytes fetched before playback starts, the measured time to first frame, and the bytes wasted if the visitor never presses play. Colour marks each cell as cheap, moderate or costly. One 4.2 MB video, four preload configurations, measured at 6 Mbps Configuration Bytes before play Time to first frame Wasted if never played preload="none" 0 KB 1.80 s nothing preload="metadata" 42 KB 1.10 s 42 KB preload="auto" up to 4.2 MB 0.30 s up to 4.2 MB manifest + first segment 380 KB 0.50 s 380 KB Measured on a 6 Mbps link with a 38 s progressive MP4 and the same title packaged as 6 s HLS segments. preload="auto" is a hint, not a command: Safari on iOS clamps it to metadata behaviour on cellular.

The bottom row is the configuration most sites should be running for hero video: preload="none" on the element, plus an explicit preload of the adaptive manifest and the first media segment, dispatched only once the player is in or near the viewport. It buys 1.3 s of the 1.8 s that preload="none" alone costs, for 9% of the bytes that preload="auto" spends. The full pattern — including how to pick the starting rendition without triggering a mid-playback quality drop — is covered in preloading video and media streams, with the two decisions broken out separately in choosing video preload=“metadata” vs “auto” and preloading HLS and DASH manifest segments.

Two media-specific traps are worth naming here. First, as="video" and as="audio" produce requests without a Range header, so a byte-range-serving origin returns 200 with the whole file rather than 206 with the header — the player then issues its own ranged request and the preload is wasted entirely. Second, media requests are exempt from some of the scheduler’s fairness rules and can saturate the connection; if your LCP element sits beside a video, measure the LCP with the video element removed to see how much of the delay it owns.


Diagnostics & Tooling

Chrome DevTools — Network Panel

  1. Open DevTools → Network tab. Enable the Priority column: right-click any column header → check “Priority”. Enable Initiator to identify which mechanism dispatched the hint (preload scanner vs. parser vs. JS).
  2. Filter by resource type (JS, CSS, Font, Image). Verify that preloaded LCP assets appear with Highest or High priority and a near-zero Queueing time — a non-zero queue means the hint arrived too late or the socket pool is saturated.
  3. Look for yellow triangle warnings on preloaded resources — these indicate the asset was preloaded but not used within the expected window (the “unused preload” warning). Remove or delay-inject those hints.
  4. Check the Initiator column: <link rel=preload> initiated resources should show Other as their initiator once consumed (the preload cache is transparent). If you see the real URL again in a second row, you have a CORS mismatch — the consuming <img> or <script> tag is fetching with different credentials than the preload hint.
  5. Sort by Start Time, not by waterfall position. Two requests that look adjacent in the waterfall can be 300 ms apart in dispatch; the start-time ordering is the only view that shows what the scheduler actually decided.

Network Waterfall Interpretation

Understanding the exact timing columns in the waterfall is essential for diagnosing whether a hint is working. For a detailed breakdown of each timing segment (DNS, Connect, SSL, TTFB, Download), see the guide on decoding the Chrome DevTools network waterfall.

The single most diagnostic signature for hint work is a successful preconnect: the hinted origin’s first real request shows DNS, Connect, and SSL all at 0 ms, because those phases were paid earlier on a connection the request simply reuses. If those bands are still present on the first request to an origin you preconnected, the hint either fired too late, was dropped for exceeding the socket budget, or is pointing at a different origin than the request (a www. mismatch or an unexpected redirect will do it).

ResourceTiming API

// Audit all preload-initiated resources and their fetch start deltas.
// A fetchStart close to 0 ms confirms the preload scanner fired early.
// A large responseEnd - fetchStart with a small transferSize confirms cache hit.
performance.getEntriesByType('resource')
  .filter(r => r.initiatorType === 'link')
  .map(r => ({
    name: r.name.split('/').pop(),
    fetchStart: r.fetchStart.toFixed(0) + ' ms',
    duration: r.duration.toFixed(0) + ' ms',
    transferSize: r.transferSize + ' bytes',
    fromCache: r.transferSize === 0 && r.duration < 5
  }))
  .forEach(r => console.table(r));

For field monitoring rather than lab work, the more useful query is the inverse: find resources whose renderBlockingStatus is blocking and whose startTime is late, which is the population your hints should be shrinking.

// Field check: render-blocking resources discovered later than one round trip.
// Ship the result to your RUM endpoint; a rising count means a hint regressed.
const late = performance.getEntriesByType('resource')
  .filter(r => r.renderBlockingStatus === 'blocking' && r.startTime > 400)
  .map(r => ({ url: r.name, startTime: Math.round(r.startTime) }));

if (late.length) navigator.sendBeacon('/rum/late-blocking', JSON.stringify(late));

Lighthouse Audits

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

  • “Preload key requests” — Lighthouse identifies LCP-critical resources not covered by a preload hint and estimates the potential savings.
  • “Preconnect to required origins” — flags third-party origins with significant connection latency that lack a preconnect hint.
  • “Avoid enormous network payloads” — a high total byte count from preloads indicates over-preloading; prune hints for below-fold assets.

Treat the first two as leads rather than instructions. Lighthouse computes “Preload key requests” from a simulated dependency graph and regularly recommends preloading a resource that is already discovered in the first flight, where the hint changes nothing and adds a queue entry. Verify each suggestion against a real trace before shipping it.

WebPageTest

Use the Connection View waterfall to see DNS, Connect, SSL, and Request/Response bands separately for each hinted resource. Compare the connection start time for preconnected origins against unpreconnected ones; a successful preconnect removes the stacked DNS+Connect+SSL band from the critical path entirely.


Common Failure Modes

Priority Inversion from Missing as Attribute

Symptom: A <link rel="preload"> fires early but the resource still loads at Low or Lowest priority, arriving after the browser’s own discovered resources.

Cause: Without as, Chromium cannot determine the resource type and assigns the fetch to the generic Other bucket, which receives the lowest scheduling weight in the network priority queue.

Fix: Always set as to the correct destination type (script, style, font, image, fetch). For module scripts use rel="modulepreload" instead of rel="preload" as="script" — the latter does not set the correct CORS mode.

CORS Double-Fetch

Symptom: DevTools shows two requests for the same font or script URL — one with (preload) in the Initiator column and one without. Both complete, doubling the bandwidth cost.

Cause: The <link rel="preload"> hint is missing crossorigin="anonymous" but the consuming <script type="module"> or @font-face rule fetches with CORS mode. The browser treats these as different requests and cannot share the cached response.

Fix: Mirror the crossorigin attribute exactly between the preload hint and the consuming element. For fonts, always add crossorigin="anonymous" regardless of origin. See mastering link rel preload & prefetch for the full CORS interaction model.

Socket Pool Exhaustion from Over-Preconnecting

Symptom: Adding more preconnect hints slows down the first critical request instead of speeding it up. The Network panel shows the LCP resource with a non-trivial Stalled time.

Cause: Each preconnect hint consumes a socket from the browser’s pool (6 sockets per origin under HTTP/1.1; effectively 1 per origin under HTTP/2 but still a connection). Too many preconnects starve the actual critical-path requests of available sockets.

Fix: Limit preconnect to 3–4 origins maximum. Replace non-critical origin hints with dns-prefetch — the trade is quantified in preconnect vs dns-prefetch: a decision matrix. To avoid head-of-line blocking under HTTP/2 stream contention, consolidate assets behind a single CDN origin where possible.

Unused Preload Warning (Wasted Bandwidth)

Symptom: Lighthouse flags “Avoid unused preloads” for assets that are preloaded but never consumed within the load window, or consumed only after a JS condition resolves.

Cause: Unconditional preloads for assets that are rendered conditionally (dark-mode images, authenticated-only UI components, viewport-dependent resources without a media query).

Fix: Scope preloads with media attributes for viewport-specific assets. For conditional assets, switch from static HTML preloads to dynamic hint injection via JavaScript so the hint fires only when the condition is known.

Prefetch Cache Miss After Navigation

Symptom: Prefetching a next-page resource appears to work in DevTools (the request completes at Lowest priority), but on navigation the browser re-fetches the asset instead of serving it from cache.

Cause: The prefetch cache is separate from the HTTP cache and has a 5-minute TTL. If more than 5 minutes elapse between the prefetch and the navigation, the entry is discarded. Additionally, prefetched resources are partitioned by top-level origin (privacy partitioning) — a prefetch on one site does not warm the cache for the same resource on another.

Fix: Time prefetches to user intent signals (hover, focus, scroll into a CTA’s viewport) rather than page load. Use the Cache-Control headers vs resource hints decision matrix to determine whether a long-lived HTTP cache entry is more reliable than a prefetch for your navigation pattern. Where the whole next document is the goal, speculation rules supersede link rel=prefetch entirely.

Responsive Image Preloaded at the Wrong Candidate

Symptom: The Network panel shows two hero images downloading: the one named in the preload’s href, and a different srcset candidate requested by the <img> a few hundred milliseconds later. LCP lands on the second.

Cause: The preload hint has no knowledge of the <img> element’s srcset/sizes selection unless you restate it. It fetches href verbatim; the <img> then runs candidate selection and picks a different file.

Fix: Add imagesrcset and imagesizes to the hint, mirroring the element exactly, as in implementation pattern 4 above. Keep the two lists generated from the same source of truth in your template so they cannot drift.

Preload Racing a Blocking Script

Symptom: A correctly configured preload shows a long Queueing or Stalled segment despite Highest priority, and the resource lands after a large third-party script.

Cause: Priority governs order within the queue, not admission to the network. A parser-blocking script already occupying the connection will not yield, and on HTTP/1.1 a saturated six-socket pool holds the preload until a socket frees. This is a scheduling contention problem, not a hint problem.

Fix: Remove the contention rather than raising the priority further: defer or async the offending script, or move it behind a facade. The ordering model is in script loading: async, defer and execution order.


FAQ

Does a preload hint make a resource load faster, or just earlier?

Only earlier. A preload does not change the transfer rate, the compression, or the server’s response time. It moves the moment the request is dispatched, which is worth 100–300 ms on a cold load when the resource would otherwise have been discovered after the CSSOM was built. If the resource was already discoverable in the first 14 KB of HTML, the preload buys nothing and simply adds a second competitor for the same bandwidth.

Why does my preloaded font get fetched twice?

Because the hint and the consuming request do not agree on CORS mode. A font referenced from an @font-face rule is always fetched in CORS mode, even same-origin, so the hint must carry crossorigin="anonymous". Without it the two requests have different cache keys, the preload cache never matches, and the browser downloads the file a second time at the moment the CSSOM applies the rule.

How long does a preloaded resource stay in the preload cache?

Roughly three seconds after the load event in Chromium. If nothing consumes the entry in that window the browser drops it and logs the “preloaded but not used” console warning. The bytes may still be reusable from the HTTP cache afterwards, but only if the response carried a cacheable Cache-Control header; an uncacheable response that is preloaded and not consumed is pure waste.

Is prefetch still worth using now that speculation rules exist?

Yes, for subresources. Speculation rules prefetch and prerender whole documents and are the better tool for next-page navigation, but they do not warm an individual script chunk, image, or JSON payload. Use link rel=prefetch when you know a specific subresource the next view needs, and speculation rules when you want the whole document ready. They coexist without conflict.

How many preconnect hints is too many?

Three or four. Each preconnect performs a DNS lookup, a TCP handshake, and a TLS negotiation that competes with the critical path for both CPU and bandwidth on a mobile device, and it holds a socket that is recycled after roughly ten seconds of idle. Past four origins the handshakes measurably delay the first critical request instead of accelerating it; downgrade the rest to dns-prefetch.

Does preload work for video?

Partially. A link rel="preload" as="video" fetch is honoured in Chromium but it fetches whatever byte range the server offers, which is almost never what you want for a multi-megabyte file. The reliable levers are the <video> element’s own preload attribute, a Range request for the container header, and — for adaptive streams — a preload of the manifest plus the first media segment.

Why does the Priority column show Low on a resource I preloaded with fetchpriority="high"?

Two common causes. Either the as attribute is missing or wrong, in which case Chromium files the request under the generic Other destination and fetchpriority cannot lift it above the resource-type baseline; or the hint is attached to a destination whose baseline is deliberately capped, such as a prefetched document, which stays at Lowest regardless of the attribute.

Should resource hints live in the HTML or in an HTTP header?

Headers for the handful of assets every response on that route needs, HTML for everything conditional. A Link header is dispatched before the browser has parsed a single byte of the body, which is the earliest possible point, but it is unconditional: it cannot carry a media query and it fires even when the asset turns out to be unused. Keep header preloads to two or three entries and express everything viewport-dependent or state-dependent in markup.

Do resource hints survive a cross-origin navigation?

No. Both the preload cache and the prefetch cache are partitioned by top-level site, so a prefetch performed on one site does not warm the entry for another site even when the URL is byte-identical. Shared-CDN warming across sites stopped working when cache partitioning shipped; plan prefetches only for navigations that stay within your own site.

Can a modulepreload replace preload for ES modules?

It should. modulepreload sets the script destination, applies the module CORS rules, and — crucially — tells the browser to parse and compile the module and to walk its static import graph so dependencies are fetched in the same round trip. link rel="preload" as="script" fetches the bytes only, leaves the graph undiscovered, and uses the wrong credentials mode for module scripts.