What you'll learn
- Quick answer
- What Resource Hints Actually Are
- dns-prefetch and preconnect: Paying the Connection Tax Early
- preload: Get a Specific, Known Resource Early
- prefetch and modulepreload: Betting On What's Next
- How to Actually Measure the Difference
- Common Mistakes That Make Resource Hints Backfire
- Quick Reference: Which Hint, When
- FAQ
Quick Answer
Resource hints are small
<link>tags you drop into a page's<head>that tell the browser about a resource before it would normally discover it, so the browser can get a head start on DNS lookup, connection setup, or the download itself. Used on the right resource, they can shave real time off page load. Used on the wrong one, they waste bandwidth and can slow the page down instead.
What Resource Hints Actually Are
A browser discovers most resources the slow way: it parses your HTML from the top, and the moment it hits a <link rel="stylesheet">, an <img>, or a <script> tag, only then does it know that resource exists and start fetching it. For a resource referenced deep inside a CSS file (a font inside @font-face, say) or one that only shows up after some JavaScript runs, discovery happens even later. Every millisecond between "page starts loading" and "browser knows it needs this resource" is dead time that hints exist to cut out.
There are five standard resource hints, all written as <link rel="..."> tags in the <head>:
dns-prefetch— resolve a domain's DNS now, before you need anything from it.preconnect— do the full connection setup (DNS + TCP + TLS) for an origin now.preload— fetch one specific, known resource now, at high priority.prefetch— fetch a resource at low priority, for a future navigation.modulepreload— fetch an ES module (and set up its dependency graph) ahead of time.
Every one of them is a hint, not a command. Browsers can and do ignore them under memory pressure, on metered connections where the user has Data Saver turned on, or simply because they've decided the priority queue is already full. That's a feature, not a bug — it means hints are safe to add defensively; a browser that can't honour one just falls back to discovering the resource normally.
The mental model worth keeping: hints don't make a browser work harder, they make it start working earlier. If a resource was already going to load fast because it's small and early in the document, a hint won't help it. Hints only pay off for resources that are expensive to discover (buried in CSS/JS) or expensive to connect to (a third-party origin your main document doesn't talk to otherwise).
dns-prefetch and preconnect: Paying the Connection Tax Early
Before a browser can request a single byte from a new origin, it has to pay a connection tax: resolve the domain name (DNS lookup), open a TCP connection (handshake), and negotiate TLS (another handshake) if it's HTTPS. Each of those steps costs at least one network round trip, and they happen in sequence, not in parallel.
Here's a worked example using plain round-trip-time math, run as a small calculation rather than asserted from memory, so you can see exactly where the numbers come from (not a live network capture, but the same arithmetic DevTools' waterfall is built on):
const rtt = 100; // ms, a realistic mid-tier mobile round trip
const dns = rtt; // 1 RTT to resolve the domain
const tcp = rtt; // 1 RTT for the TCP handshake
const tls = rtt * 2; // ~2 RTTs for a full TLS handshake
const connectionSetup = dns + tcp + tls; // 400ms
// The browser only discovers a third-party resource after parsing
// far enough into the HTML to see its tag — say 150ms in.
const discoveryDelay = 150;
// No hint: discovery delay, THEN the full connection tax, THEN the fetch.
const noHint = discoveryDelay + connectionSetup; // 550ms
// With preconnect in <head>: the browser starts the connection
// immediately, in parallel with parsing the rest of the HTML. By the
// time the real resource is discovered, part of that 400ms is already spent.
const stillExposed = connectionSetup - discoveryDelay; // 250ms
const timeSaved = noHint - (discoveryDelay + stillExposed); // 150ms
In this scenario, preconnect hides exactly as much of the connection tax as it had time to overlap with HTML parsing — 150ms out of 400ms. The real number on your site depends on your actual RTT and how early the hint sits relative to the resource's normal discovery point, but the shape of the win is always the same: overlap, not elimination.
The code is deliberately tiny:
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
dns-prefetch only resolves the domain name — cheap, roughly one round trip, and it's the hint browsers will honour even when they're reluctant to open a full connection speculatively. preconnect does the whole job: DNS, TCP, and TLS, leaving a warm, ready-to-use connection sitting there. That's a bigger win when the resource does show up, but it's also a bigger cost to the browser to maintain — most engines will close an unused preconnected socket after roughly 10 seconds, and holding several open at once competes for the same CPU and memory budget as the page's actual critical-path work. A practical rule: use preconnect for 2-4 origins you're genuinely about to need (your font host, your main API, your CDN), and fall back to the cheaper dns-prefetch for anything more speculative than that.
preload: Get a Specific, Known Resource Early
preload is the most targeted of the five hints: you name one exact resource, and the browser fetches it at high priority, in parallel with everything else, without blocking rendering. It exists for the resources a browser wouldn't normally prioritise on its own — most commonly, a font referenced only inside a CSS @font-face rule (which the browser can't see until it's downloaded and parsed the CSS that mentions it), or a hero image set as a CSS background-image rather than an <img> tag.
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin>
Two details here are not optional, and both are common sources of a wasted hint:
- The
asattribute tells the browser what kind of request this is (font,style,script,image...), which determines the request's priority and which cache entry it's allowed to reuse. Get it wrong, or leave it out, and Chrome will fetch the resource twice — once for the preload, once for the real request — because they don't share a cache key. crossoriginis required on preloaded fonts even when they're same-origin, because the browser always fetches fonts in anonymous CORS mode. Without it, the preload and the real font request are, again, treated as different cache entries, and you've downloaded the font twice for nothing.
This is exactly the warning you'll see in Chrome DevTools' console as "The resource ... was preloaded using link preload but not used within a few seconds" — it almost always means a mismatched as/type/crossorigin caused the real request to miss the preloaded cache entry, not that the browser is being overly cautious.
Use preload sparingly, on resources that are genuinely on your Largest Contentful Paint (LCP) critical path — the hero image, the font your headline uses, the one JS chunk your interactive above-fold widget needs. Preloading things further down the page steals bandwidth and priority from the resources that are actually blocking what the user sees first, which can make your real metrics worse even though you've technically "optimised" something.
prefetch and modulepreload: Betting On What's Next
prefetch is the low-priority, future-facing cousin of preload. Instead of "fetch this now, it's urgent," it means "fetch this during idle time, because the user will probably need it on the next navigation." It's a good fit anywhere the next click is predictable: a "next lesson" link on a course page, step 2 of a checkout flow, the next page of a paginated article.
<link rel="prefetch" href="/courses/python" as="document">
This site's own framework, Astro, builds a version of this in directly — adding data-astro-prefetch to a link tells Astro's router to prefetch that page's HTML automatically once the link scrolls into view or is hovered, without you writing the <link rel="prefetch"> tag by hand. The underlying mechanism is the same idea the spec describes: low-priority, speculative, and skipped outright for users on a metered connection (the browser respects the Save-Data header and typically disables speculative prefetching for them, which you should treat as a signal to respect, not route around).
modulepreload solves a narrower problem specific to ES modules. A plain preload fetches exactly the one file you named — if that file has import statements pulling in three more modules, those three are only discovered (and fetched) after the first one is parsed. modulepreload fetches the named module and sets up fetching for its static import graph ahead of time, which matters once your app ships more than a single flat bundle:
<link rel="modulepreload" href="/scripts/app.js">
You don't need a bundler to benefit from this — it works with plain native ES modules loaded via <script type="module"> just as well as with a bundler's output. It's most worth reaching for when you already know your module graph (you control the imports) and want to collapse what would otherwise be a multi-round-trip waterfall of "fetch, parse, discover next import, fetch again" into one upfront batch.
How to Actually Measure the Difference
Don't ship a resource hint on faith — measure it, because the win is small enough on a fast connection that it's easy to convince yourself a hint helped when it didn't. Two ways to check, both free:
DevTools Network panel. Open the waterfall, click the resource in question, and look at the Timing tab. You'll see the request broken into exactly the phases the worked example above modelled: Queueing, DNS Lookup, Initial Connection, SSL, then Waiting (TTFB) and Content Download. With a working preconnect in place, DNS Lookup and Initial Connection/SSL for that origin should collapse to near-zero on the request that actually uses the warmed connection, because the handshake already happened on an earlier, parallel request.
The Resource Timing API, in code. You don't need DevTools open to get these numbers — they're available at runtime:
const entries = performance.getEntriesByType("resource");
for (const entry of entries) {
console.log(entry.name, {
dnsTime: entry.domainLookupEnd - entry.domainLookupStart,
connectTime: entry.connectEnd - entry.connectStart,
tlsTime: entry.connectEnd - entry.secureConnectionStart,
ttfb: entry.responseStart - entry.requestStart,
});
}
If domainLookupEnd - domainLookupStart and connectEnd - connectStart are both already zero (or very close to it) for a resource you hinted, the hint did its job — that work happened earlier, on a separate, overlapped request, and isn't showing up on this one's critical path anymore.
For a page-level view rather than a single resource, Lighthouse's "Preconnect to required origins" and "Preload key requests" audits will flag candidates automatically, and will also flag the opposite problem — an existing preload that nothing on the page actually used within the page load, which is wasted bandwidth sitting in your HTML. Run Lighthouse after adding a hint, not just before, since it's just as good at catching a hint you added that didn't help as one you're missing.
Common Mistakes That Make Resource Hints Backfire
Every one of these has shipped on a real production site at some point, usually added with good intentions:
- Too many preconnects. Each one opens and holds open a real TCP+TLS connection. Past roughly 4-6 genuine origins, you're spending CPU and memory on handshakes for connections that won't be used soon, and that work competes directly with the resources actually on the page's critical path right now.
- Preload without a matching
as/type/crossorigin. Covered above, but worth repeating because it's the single most common cause of "my preload made things slower" — the resource gets fetched twice instead of once. - Preloading something that isn't actually critical. A below-the-fold image or a font used only in a footer doesn't belong on
preload. Every byte of bandwidth and every slot in the browser's priority queue that it consumes is bandwidth and priority taken away from whatever genuinely is blocking first paint. - Using
prefetchfor large resources regardless of connection type. Browsers skip speculative hints under Data Saver mode, but if you're hand-rolling prefetch logic in JavaScript rather than relying on the browser's own handling, checknavigator.connection?.saveDatayourself before firing it off. - Hints with no matching real request at all. A
preconnectto an origin the page never actually talks to, left over from a removed third-party script, is pure overhead with zero offsetting benefit. Resource hints are cheap to add and easy to forget to remove — audit them whenever you remove a third-party integration.
The common thread: a resource hint is a bet that a specific resource is both expensive to reach and on the critical path. Get either half of that wrong and the hint either does nothing or actively costs you something.
Quick Reference: Which Hint, When
| Hint | What it does | Best for | Overuse cost |
|---|---|---|---|
dns-prefetch | Resolves DNS only (~1 RTT) | Speculative third-party origins you might need | Low — cheap enough to add generously |
preconnect | Full DNS + TCP + TLS handshake | 2-4 origins you're about to request from | Medium — each held-open connection costs memory/CPU |
preload | Fetches one exact resource, high priority | Fonts in CSS, hero images, the one critical JS/CSS chunk | High — steals priority from everything else if overused |
prefetch | Fetches a resource at low/idle priority | Predictable next navigation (next lesson, checkout step 2) | Medium — wasted bandwidth if the guess is wrong |
modulepreload | Preloads an ES module and its static imports | Known module graphs, native ESM or bundler output | Low-medium — same bandwidth risk as preload |
If you only add one hint to a page today, make it a preconnect to the origin serving your LCP image or font, and check the Network panel afterwards to confirm the handshake actually moved earlier. That single change is usually the highest-value, lowest-risk resource hint on any page.
A reasonable rollout order for a real project, rather than adding all five at once: start with preconnect on your two or three highest-impact third-party origins (font host, main API, image CDN), since it's the hint with the clearest, most immediately measurable win in DevTools. Add preload next, but only after you've confirmed with the Network panel which specific resource is actually on your LCP critical path — guessing which resource to preload without checking first is how most preload-related regressions happen. Save prefetch and modulepreload for later, once the more impactful hints are in place and verified, since both depend on knowing your navigation patterns or module graph well enough to bet correctly on what comes next.
Frequently Asked Questions
Do resource hints block rendering?
No. All five hints are asynchronous and non-blocking by design — they run alongside normal parsing and rendering, not instead of it. That's also why they're safe to add defensively: a hint the browser can't act on immediately is simply deferred or skipped, never something that holds up the page.
How many preconnects is too many?
There's no hard browser-enforced limit, but practically, 4-6 genuine third-party origins is the usual ceiling people recommend. Past that, each additional preconnect is a held-open TCP+TLS connection competing for the same CPU and memory budget as the resources actually loading right now, often with no resource ever arriving to use some of them.
Does preload actually move Core Web Vitals, or is it just a network trick?
It can move Largest Contentful Paint directly. If your LCP element is an image or uses a font that's otherwise discovered late (inside CSS, or after a JS chunk runs), preloading that exact resource is one of the few changes that reliably pulls LCP earlier, because it removes a real discovery delay from the critical path rather than just reordering priorities.
Do I need a bundler to use modulepreload?
No. It works with plain native ES modules loaded through <script type="module">, not just bundler output. It's most useful whenever you know your module's import graph ahead of time, bundled or not.
Will my resource hints still fire on a slow or metered connection?
Speculative hints like prefetch are commonly skipped by the browser when a user has Data Saver enabled, since they're a bet on a future need rather than something the current page requires. preload and preconnect for genuinely critical-path resources are generally still honoured, since skipping them would make the page the user is actually loading right now slower, not faster.
Where in the HTML should resource hints go?
As early as possible inside <head>, ideally before other resource-heavy tags like large inline <style> blocks. The entire value of a hint comes from how early the browser sees it relative to when it would have discovered the resource normally — a hint buried after a large blocking resource has already given up most of its head start.
