01 — The Cost of Late Discovery
Before any byte of any resource transfers, the browser pays a connection tax. For a brand-new origin, that's DNS lookup (~50ms) TCP handshake (~50ms) TLS negotiation (~100ms) — a full 200ms before the first request flies. Page-internal resources reuse the document's connection, so they skip this overhead. Third-party origins — your font CDN, your analytics endpoint, your image service — each pay it independently.
Worse: the browser doesn't know an origin exists until it parses the markup that references it. A <link> in <head> discovers the stylesheet around 60ms in. The stylesheet contains @font-face url(...) which discovers the font around 320ms in. The hero <img> inside the SPA shell isn't found until JS executes around 520ms. Each step is a serial dependency.
Resource hints are eight short HTML signals that flatten this chain. Some warm a connection. Some start downloads before the browser would normally discover them. The most aggressive ones prerender entire pages in a hidden tab. The lab on the right starts at the unhinted baseline — a timeline of eight resources with all the connection tax visible. Each step adds one hint and watches the timeline shift left.
Watch the timeline. The bottom legend separates the connection phases (DNS, TCP, TLS) from the body download so you can see exactly which seconds each hint reclaims.
02 — dns-prefetch vs preconnect
Two hints, two levels of commitment. Both target the discovery cost; they differ in how much of the handshake they pre-pay.
<link rel="dns-prefetch" href="//a.example.com"> asks the browser to resolve only the DNS record early. That's the cheapest phase (~50ms) and the lowest-commitment hint. Use it for origins you're not yet sure you'll need — analytics that may fire only on interaction, a chat widget that's lazy-loaded, the third checkout provider you'll only hit if the user chooses it. It's cheap insurance. Browsers happily resolve dozens of these in parallel without bandwidth cost.
<link rel="preconnect" href="https://cdn.example.com" crossorigin> does DNS + TCP + TLS — the full ~200ms handshake. When the resource is finally requested, it skips straight to the HTTP request. Use it for origins you know you'll hit: your CDN, your font provider, your primary API. The cost is one open TCP socket per origin, which competes for the per-origin connection cap (typically 6 on HTTP/1, less of an issue on HTTP/2 thanks to multiplexing).
A common production combination: <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> for your font CDN (you know you need it) paired with <link rel="dns-prefetch" href="//metrics.example.com"> for a deferred metrics beacon. The crossorigin attribute on preconnect is required for CORS resources — without it, the browser opens two separate connections (one for CORS, one for non-CORS) and your preconnect wasted a slot.
Historical context: HTTP/2 server push was supposed to be the “send it before you're asked” pattern, but Chrome removed it in May 2022 — push streams almost always race the browser's own cache lookup and lose. Modern stacks replaced server push with 103 Early Hints: a tiny HTTP response sent before the 200, carrying Link: </styles.css>; rel=preload; as=style headers. The browser starts preloads before the HTML even finishes generating. Cloudflare, Fastly, and Vercel ship this today.
The lab's Step 2 wires up both hints at once — dns-prefetch on the analytics origin slides DNS leftward by ~50ms, and preconnect on the font CDN reclaims a full ~200ms (DNS + TCP + TLS) before any download begins. The dashed ghost bars show the baseline timing each hint replaces.
03 — preload (and Why Fonts Demand crossorigin)
<link rel="preload"> tells the browser: “you WILL need this resource for the current page — start fetching it now, before parser-driven discovery would find it.” Where dns-prefetch and preconnect warm the connection, preload starts the download.
The problem preload solves is late discovery. Fonts are the textbook case: the browser downloads HTML, finds the CSS <link>, downloads the stylesheet, parses it, encounters @font-face, and only then discovers the font URL. That's three serial steps before the font fetch even begins. With preload, the font starts downloading alongside CSS — the discovery chain collapses from sequential to parallel.
Four attributes, all load-bearing:
asis mandatory. It tells the browser the request priority, the Accept header, and which CORS rules apply. Drop it and the console warns “A preload for ... was used but not loaded” and the resource fetches at the lowest priority — exactly the opposite of what you want.typelets the browser skip the preload if it doesn't support the format (so a Safari user without AVIF support won't waste bytes pre-fetching an AVIF).hrefmust be byte-exact — the preload only matches the eventual fetch if the URL,as, and CORS mode all align.crossoriginis the trap.
<link rel="modulepreload"> is preload's smarter sibling for ES modules. Regular preload downloads the bytes and stops. modulepreload downloads, parses, resolves the import graph, and compiles the code — all during idle time before the module actually executes. For a deeply imported entry point (10+ files), the parse-and-resolve phase can exceed the download time. modulepreload eliminates that entire tail. The browser also auto-fetches transitive dependencies, so you only need to hint top-level chunks.
Chrome DevTools surfaces unused preloads with “The resource was preloaded but not used within a few seconds.” Treat that warning as a regression signal — every unused preload is bandwidth you spent on the critical path for nothing.
Common mistake: preloading too much. Every preloaded resource competes for bandwidth with every other resource. Preload your LCP image, your primary font, and maybe one critical above-the-fold script. Beyond ~4 preloads, you're saturating bandwidth — see Step 5.
Watch the timeline jump in the lab as Step 3 adds both preload to the font and modulepreload to the admin chunk. The font bar slides left to start at ~30ms, parallel with CSS, instead of after CSS parses. The admin chunk's download bar gets a thin parse-and-compile overlay underneath — the bytes are coming in AND the module graph is being resolved simultaneously.
04 — fetchpriority (the 2024+ Replacement for <link rel=preload as=image>)
fetchpriority="high" is the newest hint in this set (Chrome 101+, Safari 17.2+, Firefox 132+ — now baseline-supported as of 2024). It's a single HTML attribute that overrides the browser's default priority queue. Browsers assign priorities by resource type: stylesheets and synchronous scripts get high, images get medium, async scripts get low. But the browser doesn't know which image is your LCP element and which is a decorative below-fold icon.
For LCP images, fetchpriority="high" has REPLACED <link rel="preload" as="image">. The old preload pattern still works, but the modern guidance (web.dev, 2023) is to use the attribute on the <img> itself. Three reasons:
- Simpler. One attribute instead of a
<link>+ matching<img>. - Plays with
srcset. The browser picks the right variant then upgrades its priority. A preload would have to hard-code one URL. - No “late discovery” benefit anyway. The image is already in the HTML — the browser sees it during the same
<head>+<body>parse pass. There's no chain to short-circuit; only a priority lane to skip ahead in.
Where it shines beyond LCP images:
<script fetchpriority="low">for a non-critical analytics script — lets render-blocking JS stay ahead.<link rel="preload" as="image" fetchpriority="high">for an image triggered by JS state (so it's both preloaded AND prioritized).<link rel="prefetch" fetchpriority="low">for a next-page chunk — explicitly drops it below any current-page work.
The lab's Step 4 promotes the hero image to high priority via fetchpriority. Watch the hero bar move ahead of analytics.js and other medium-priority assets in the same lane.
05 — Priority Inversion: When Everything Is High, Nothing Is
Here's the trap. The reader who's just learned preload and fetchpriority="high" thinks: if these are good, more of them are better. Step 5 in the lab plays this out: three card images get added to the preload list to “make the gallery load faster.” The cumulative count hits 5 high-priority hints, the guardrail at the bottom of the lab turns yellow, and the load endpoint moves the wrong way.
Browsers cap useful concurrency. On HTTP/1.1 there's a hard 6-connection-per-origin limit; HTTP/2 multiplexes but still allocates bandwidth proportionally. When five or more resources all yell “I'm high priority,” none of them gets the full pipe. The system saturates and each preloaded download stretches — in the simulation, by ~35%.
When the guardrail trips, watch the card image bars in the timeline — they don't start at ~30ms like the font preload did. They sit RIGHT BEHIND the blocking CSS, because render-blocking resources occupy a strictly higher tier than High. Preload doesn't mean absolute highest; it means high inside its tier. The page-load endpoint moves right, not left, and the colored badge on the focused card flips to +85ms slower vs baseline. The lesson clicks: preload and fetchpriority are surgical, not blanket. Use them on 3–4 truly critical assets, no more.
The corollary: if you find yourself preloading everything to make any single resource faster, the real fix is somewhere else — too much render-blocking CSS, too many third-party origins, or a missing CDN layer.
06 — prefetch and the Bandwidth Tradeoff
<link rel="prefetch" href="/chunks/products.mjs"> loads resources for future navigations at the lowest possible priority. The key word is “lowest” — prefetch never competes with the current page's resources. It only fires when the browser has idle bandwidth.
The strategy is anticipation. You're betting the user will navigate to /products next, so you front-load that route's code into the disk cache while the current page is idle. When they click, the navigation is instant — the JS is already there, no network round-trip required.
The bandwidth tradeoff is real. Prefetching a 200KB route chunk for a page the user never visits is 200KB of wasted data. On metered mobile, that matters — both for cost and for battery (every byte downloaded is radio-on time). Modern guidance:
- Prefetch only the top 1–2 most likely next routes (informed by analytics or viewport visibility).
- Respect the Save-Data request header:
if (navigator.connection?.saveData) skipPrefetch();— users on metered plans opt out. - Respect
prefers-reduced-dataonce it lands in CSS-MQ4 (Chrome flag today). - Cap your prefetch budget at ~1MB per page.
A prefetch request lives at the resource level — it downloads files into the HTTP cache. It does not execute JavaScript or render HTML. For full pre-execution, jump to Speculation Rules in the next step.
The lab's Step 6 wires up <link rel="prefetch"> on the /products chunk. The products.mjs bar appears for the first time on the timeline, but it starts AFTER the main load endpoint — using truly idle bandwidth. The bar is shaded at 55% opacity and tagged idle to make the low-priority lane visible.
07 — Speculation Rules (and All Four Eagerness Levels)
The Speculation Rules API (Chrome 121+) is the most aggressive hint: it prerenders entire pages in a hidden tab. Where prefetch downloads bytes, prerender does everything — downloads resources, parses HTML, runs JavaScript, computes layout, completes hydration. When the user clicks, the browser swaps the hidden tab to the foreground. The page is already painted; the navigation feels like switching tabs (effectively ~0ms).
rel="prerender" (the old single-page hint) is deprecated. The Speculation Rules API replaces it — never reach for <link rel="prerender"> in new code.
The cost is real: a prerendered page consumes the same CPU and memory as a visible tab. Prerendering 10 candidate pages means 10 hidden tabs running JavaScript. The API provides eagerness levels to control when prerendering starts. There are four, and choosing the right one is the entire art of using this API safely:
| Eagerness | Trigger | Use case |
|---|---|---|
immediate | On page load | Single highest-confidence next route — only when conversion data says >70% of users go there |
eager | As soon as the link is selected/hovered | Top-of-funnel navigation links the user is reading |
moderate | On hover/pointerdown for ~200ms | Default — balances accuracy and cost |
conservative | On pointerdown / touchstart | When memory matters more than instant nav (low-end Android) |
The full grammar supports prerender and prefetch rules separately, URL patterns (href_matches), CSS selectors (selector_matches), and combinators (and, or, not). A practical rule for an e-commerce category page: prerender the first 3 product cards (moderate), prefetch the next 10 (eager), do nothing for the rest. Tune from analytics — prerender candidates with click-through rates above ~40%, prefetch the next tier.
Browser support note: Speculation Rules are Chromium-only. Safari and Firefox ignore the <script type="speculationrules"> tag entirely. Always treat it as progressive enhancement — the page must work for Safari users without prerender, then also feel instant for Chrome users with it.
The lab's final step turns on Speculation Rules prerender for the /products route. The next-page.products.mjs bar now appears AFTER the main load endpoint, shaded as idle work, with a prerendered badge. The next click is the closest a web page comes to zero-latency navigation.
<!-- no resource hints — every origin pays the full connection tax -->
<head>
<link rel="stylesheet" href="/styles.css">
<script type="module" src="/app.mjs"></script>
</head>Eleven resources, no hints. The font waits ~320ms before the browser even discovers it. The hero waits ~520ms.
Each preload or fetchpriority='high' consumes a slot in the high-priority lane. The browser caps useful boosts at ~4–5 resources — past that, bandwidth saturates and the load gets slower.