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:
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, andmodulepreload, but has never shippedlink 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.fetchprioritylanded in 17.2 and is honoured most consistently on images. - Gecko / Firefox 132+ implements all four hints and
fetchpriority, but historically restrictedprefetchto cacheable, same-origin responses and drops the entry when the response carriesCache-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.
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 withfont-display: swapto 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:
- 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.
- Add
preconnectonly 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. - Use
dns-prefetch(notpreconnect) for analytics, A/B testing, and consent-management endpoints that load conditionally or after user interaction. prefetchis 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.- Cap total
preconnecthints 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:
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:
- The origin serving an LCP-critical byte gets
preconnectwithcrossoriginif it serves fonts, without if it serves images. It is always first. - The origin serving a render-blocking script or stylesheet gets the second
preconnect, if there is one you cannot remove. - 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. - 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">
2. HTTP Link Header Preload (Earliest Possible Dispatch)
# 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.
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
- 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).
- 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.
- 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.
- Check the Initiator column:
<link rel=preload>initiated resources should showOtheras 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. - 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
preconnecthint. - “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.
Related
- Mastering link rel preload & prefetch — the full attribute and CORS interaction model for the two workhorse hints
- Strategic preconnect & DNS-prefetch usage — budgeting connection warm-up across third-party origins
- Dynamic hint injection via JavaScript — runtime hints for conditional assets and SPA route transitions
- Font loading optimization & FOUT prevention —
font-display, subsetting, and metric overrides that remove the swap shift - modulepreload & ES module loading — flattening import graphs into a single round trip
- Speculation Rules: prefetch & prerender — document-level speculation and its eagerness settings
- Preloading Video & Media Streams — media preload attributes, manifest and segment warming, and the byte budgets that go with them
- Core browser loading mechanics & priority queues — the scheduler these hints are talking to