Resource loading strategies - preload, prefetch, modulepreload
Resource loading strategies are <link rel> hints that tell the browser when and how to fetch resources before the page actually needs them. Preload grabs critical assets at high priority the moment the parser sees the hint. Prefetch queues future-page assets at the lowest possible priority. Modulepreload fetches ES module graphs early without executing any code.
Theory
TL;DR
- Analogy: preload = chef grabbing ingredients needed right now (high priority). prefetch = stocking the pantry for tomorrow's recipe (background, idle only). modulepreload = prepping modular ingredients without starting to cook them.
- Priority gap: preload fires at the same priority as parser-inserted CSS. prefetch drops to the lowest network slot and only runs when nothing more important is queued. modulepreload is high priority, but scoped to ES modules.
- Decision rule: LCP image or critical font → preload. Next-page resource the user will probably need → prefetch. ES module entry point and its import tree → modulepreload.
- Gotcha:
preloadwithoutasgets ignored by Chrome with the message "Did not assign resource type". Font preloads withoutcrossorigincause a cache miss on every load.
Quick example
<head>
<!-- High priority, fires at discovery, same queue as CSS -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<!-- Font requires crossorigin or the browser fetches it twice -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<!-- Lowest priority, runs when browser is idle -->
<link rel="prefetch" href="/checkout.js">
<!-- Fetches app.mjs + all its imports in parallel, no execution -->
<link rel="modulepreload" href="/app.mjs">
</head>
<body>
<img src="/hero.avif" alt="Hero"> <!-- Already in cache, no flicker -->
<script type="module" src="/app.mjs"></script> <!-- Deps pre-fetched -->
</body>The hero image paints in under 100ms. The font loads without a flash of invisible text. App module imports resolve from cache instead of triggering a serial waterfall.
Key difference
Preload is a high-priority fetch that fires at head parse time, before the browser's normal parsing would have discovered the resource. Prefetch is speculation at idle time: the browser downloads the resource only when there is nothing more important to do, and stores it for a future navigation. Modulepreload targets ES modules specifically. It fetches the entry module, recursively resolves the import graph, and downloads all dependencies in parallel, but V8 does not parse or execute any of it at this stage. You get the full network benefit without touching the main thread until the <script type="module"> tag actually runs.
When to use
- LCP hero image or above-fold video → preload with
fetchpriority="high" - Web font referenced in critical CSS → preload with
as="font"andcrossorigin - JavaScript blocking first render → preload with
as="script" - CSS needed above the fold → preload with
as="style" - Resource on the page the user is likely navigating to next → prefetch
- ES module entry points and their full import trees → modulepreload
- Third-party scripts you need but can defer → prefetch
- Cap preloads at 4-5 per page. Each one competes for bandwidth in the same high-priority queue.
Comparison table
| preload | prefetch | modulepreload | |
|---|---|---|---|
| Priority | High (same as CSS) | Lowest (idle only) | High (modules only) |
| When it fires | Immediately at discovery | When browser is idle | Early, before <script> tag |
| Blocks render | No | No | No |
| What it fetches | Any type (as required) | Any resource type | JS modules + import graph |
| Executes code | No | No | No |
| Main use case | LCP image, critical font | Next-page navigation | SPA and ES module apps |
crossorigin required | For fonts, yes | No | Depends on CORS policy |
| When to use | Critical path now | Speculative future | Modern JS module apps |
How the browser handles this internally
When the browser hits <link rel="preload"> during <head> parsing, Chromium schedules the fetch at MEDIUM or HIGH in LoadingScheduler::Priority, the same queue as parser-inserted stylesheets. Prefetch drops to LOWEST, meaning it only runs once all higher-priority work clears.
Modulepreload (Chrome 66+, all major browsers as of 2023) uses the browser's internal ModuleGraph. It issues a fetch() for the entry module, parses the import statements to discover dependencies, and fetches all of them in parallel. V8 skips the parse and eval stages entirely at this point. That side-steps the classic module waterfall: without modulepreload, a 3-level-deep import graph means 3 sequential round trips before any code runs. With it, everything fetches in one parallel burst.
Safari 16.4 and earlier had partial support that did not recursively resolve import dependencies. Full recursive resolution arrived in Safari 17.0.
preload in practice
The as attribute is not optional. Without it, Chrome ignores the hint entirely and logs a console warning. The browser needs as to assign the correct request headers and match the pre-fetched response to the resource that later requests it.
<!-- Correct: browser knows the type, cache key matches -->
<link rel="preload" href="/api-data.json" as="fetch" crossorigin>
<!-- Browser ignores this. No cache match when the page later needs it. -->
<link rel="preload" href="/api-data.json">For fonts, crossorigin is non-negotiable. Fonts load via CORS. A preload without crossorigin and the actual font request use different cache keys. The font gets fetched twice, which defeats the entire point.
prefetch for navigation
Prefetch works well when user intent is predictable. On a product detail page, prefetch the cart script. On a login page, prefetch the dashboard bundle. The browser downloads at idle time so it never competes with the current page.
<!-- Product page: user will likely go to cart next -->
<link rel="prefetch" href="/cart.js">
<link rel="prefetch" href="/cart.css">
<!-- Blog listing: user will likely click into an article -->
<link rel="prefetch" href="/article-renderer.js">One thing to keep in mind: service workers intercept prefetch requests exactly like any other fetch. If your SW has a cache-first strategy for a URL, the prefetched resource may come from the SW cache rather than the network.
modulepreload for ES module apps
Without modulepreload, an ES module app loads serially: fetch app.mjs, parse it, discover import { utils } from './utils.mjs', fetch utils.mjs, parse it, discover another import, and so on. A module graph 3 levels deep means 3 sequential round trips before any code runs.
<!-- Without modulepreload: serial fetches, ~600ms on 3G -->
<script type="module" src="/app.mjs"></script>
<!-- With modulepreload: all fetched in parallel at discovery -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
<link rel="modulepreload" href="/api.mjs">
<script type="module" src="/app.mjs"></script>Vite does this automatically. In production builds, Vite generates <link rel="modulepreload"> for every chunk in the import graph, so the waterfall collapses to a single parallel fetch burst.
preconnect and dns-prefetch
These two operate at the connection level, not the resource level. Both are useful for third-party origins you know you will hit.
preconnect does the full early handshake: DNS lookup, TCP connection, and TLS negotiation. That saves 100-500ms on the first request to that origin.
dns-prefetch only resolves DNS. Cheaper, saves less (20-120ms). Use it for domains you might hit but cannot guarantee.
<!-- Fonts origin: you are definitely loading from here -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- Analytics: likely, but not critical path -->
<link rel="dns-prefetch" href="https://analytics.google.com">Limit preconnect to 2-3 origins. Each one keeps a TCP/TLS connection open, which has a memory cost. More than 3 and you risk burning resources on connections that time out before a request fires.
fetchpriority attribute
This is a separate lever from the hint type (Chrome 101+). It lets you bump or drop resources within their default priority bucket.
<!-- Boost LCP image above other high-priority fetches -->
<img src="/hero.jpg" fetchpriority="high" loading="eager">
<!-- Push footer tracking pixel out of the critical path -->
<img src="/tracking-pixel.jpg" fetchpriority="low">
<!-- The combination Next.js uses for its Image component -->
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high">HTTP Archive data shows LCP improvement of 30-50% when pairing preload with fetchpriority="high" on above-fold hero images. Next.js applies this pattern automatically for images with the priority prop.
Common mistakes
Missing as on preload
<!-- Chrome logs "Did not assign resource type". Fetch is silently ignored. -->
<link rel="preload" href="script.js">
<!-- Fix -->
<link rel="preload" href="script.js" as="script">prefetch on critical current-page CSS
<!-- Gets lowest priority. CSS may arrive after paint. FOUC in production. -->
<link rel="prefetch" href="critical.css" as="style">
<!-- Fix: if you need it now, use preload -->
<link rel="preload" href="critical.css" as="style">modulepreload on a classic script
<!-- Does nothing for non-module scripts. No deps resolved, no cache. -->
<link rel="modulepreload" href="bundle.js">
<!-- Only works with type="module" scripts -->
<link rel="modulepreload" href="app.mjs">
<script type="module" src="app.mjs"></script>Font preload without crossorigin
<!-- Cache miss: preload and the actual font load use different cache keys. -->
<link rel="preload" href="font.woff2" as="font">
<!-- Fix -->
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>Too many preloads at once
Preloading 8-10 resources puts them all in the same high-priority queue where they compete with each other and with render-blocking CSS. Keep it to 4-5 per page.
Real-world usage
- Next.js: auto-injects
<link rel="preload">for images with thepriorityprop and for_next/static/csschunks, usingfetchpriority="high"on LCP images. - Vite: generates
<link rel="modulepreload">for every chunk in the module graph during production build. - React 18 streaming SSR: injects modulepreload hints into the HTML stream for hydration dependencies.
- Lighthouse audit "Preload key requests" flags LCP resources that are not being preloaded.
- Chrome DevTools Network tab: Priority column shows High for preload targets and Low for prefetch candidates.
Follow-up questions
Q: What is the fetch priority difference between preload and prefetch in Chromium's scheduler?
A: Preload maps to MEDIUM/HIGH in LoadingScheduler::Priority, the same slot as parser-inserted CSS. Prefetch maps to LOWEST and only runs when no higher-priority work is queued.
Q: Does preload block HTML parsing?
A: No. The fetch runs in parallel with parsing. If you use an onload handler on the preload link to coordinate rendering, you can manually delay paint, but that is a deliberate choice, not automatic blocking.
Q: What is the difference between modulepreload and dns-prefetch?
A: modulepreload fetches full module files and resolves the entire import graph, populating the module cache with actual content. dns-prefetch only resolves the hostname to an IP address. No content is fetched at all.
Q: If a preloaded resource returns a 404, does it poison the cache?
A: No. Each subsequent request fetches fresh. You can add an error event listener to the preload link element to detect the failure and swap in a fallback, which is useful in SPA routers that generate dynamic asset paths.
Q: How does preload interact with service workers?
A: Service workers intercept preload requests the same way they intercept any fetch. If your SW has a cache-first strategy matching that URL, the pre-fetched resource may come from the SW cache rather than the network.
Q: In production you need maximum LCP. What is the full pattern?
A: Combine preload + fetchpriority="high" + loading="eager" on the LCP image element. Then measure with PerformanceObserver's largest-contentful-paint entry. On slow 3G, skipping this combo typically adds 1 second to TTI on image-heavy pages. HTTP Archive data puts the LCP gain at 30-50% for above-fold images.
Examples
Basic: preload, prefetch, and modulepreload on a single page
<!DOCTYPE html>
<html>
<head>
<!-- Preload: high priority, fires at head parse time -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<!-- Prefetch: idle priority, fires in background -->
<link rel="prefetch" href="/checkout.js">
<!-- Modulepreload: app.mjs + its imports fetched in parallel -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
</head>
<body>
<!-- hero.avif already in cache, paints on first frame -->
<img src="/hero.avif" alt="Hero image" loading="eager">
<!-- Module deps cached, no waterfall at runtime -->
<script type="module" src="/app.mjs"></script>
<!-- checkout.js already cached, transition feels instant -->
<a href="/checkout">Checkout</a>
</body>
</html>The hero image is in cache before the browser reaches the <img> tag. Font loads without a flash of invisible text. The module graph resolves from cache in roughly 10ms instead of a 3-round-trip waterfall.
Intermediate: Next.js production LCP optimization
This reflects the pattern Next.js generates internally for a page with a hero image and a custom font.
<head>
<!-- LCP image: preload + fetchpriority is the Next.js Image priority prop pattern -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<!-- Font: crossorigin required, type helps MIME detection -->
<link rel="preload"
href="/_next/static/fonts/inter.woff2"
as="font" type="font/woff2" crossorigin>
<!-- Critical CSS chunk from Next.js build -->
<link rel="preload" href="/_next/static/css/app.css" as="style">
<!-- Prefetch likely next route -->
<link rel="prefetch" href="/_next/static/chunks/dashboard.js">
</head>Without the preload + fetchpriority combo, the LCP image queues behind other high-priority resources. With it, Lighthouse reports LCP at 0.8s versus 2.5s without. CLS stays at 0 because the font is ready before any text paints.
Advanced: modulepreload with dynamic imports (collapsing the waterfall)
<head>
<!-- Declare all modules in the graph at head parse time -->
<link rel="modulepreload" href="/app.mjs">
<link rel="modulepreload" href="/utils.mjs">
<link rel="modulepreload" href="/api.mjs">
</head>
<script type="module">
// Without modulepreload:
// Round 1 - fetch app.mjs, parse it, find utils.mjs + api.mjs
// Round 2 - fetch utils.mjs + api.mjs
// Round 3 - execute: ~600ms total on 3G
// With modulepreload above:
// All three fetched in parallel at head parse time
// Dynamic import resolves from cache in ~10ms
import('./app.mjs').then(app => app.init());
</script>Chrome DevTools Network timeline shows all three modules fetching in parallel from the moment the <head> is parsed: 200ms total. Without the modulepreload declarations, they fetch serially after app.mjs parses: 600ms. That 400ms gap is visible as a TTI regression on slower connections and shows up immediately in Lighthouse.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.