Preload vs Prefetch vs modulepreload: a Decision Matrix
If you cannot answer “does the current navigation need this resource, and is it an ES module?” in one sentence, you are about to pick the wrong hint — and the wrong hint either wastes bandwidth on a resource nobody uses or leaves the render-critical asset stuck at the back of the queue.
Root cause: three hints, three different scheduler contracts
The reason these three link relations are so easy to confuse is that they share syntax — all are <link> elements with an href — while making completely different promises to the browser’s scheduler. Reaching for the wrong one is not a style error; it changes which priority tier the request lands in, which cache it populates, and whether the fetch happens now or during the next idle window.
rel="preload" is a same-navigation instruction. It tells the browser: this resource is needed by the page currently loading, fetch it now at a priority derived from its as value. A preload of as="style" inherits the Highest priority band; as="font" sits high but below render-blocking CSS; as="image" lands low unless promoted with fetchpriority. The resource goes into the in-memory preload cache and must be consumed within a few seconds or the browser logs an unused-preload warning.
rel="prefetch" is a next-navigation instruction. It tells the browser: the user will probably navigate somewhere that needs this, fetch it at the Lowest priority during idle time and store it in the HTTP cache for a future page. It deliberately never competes with the current page’s critical path — which is exactly why using it for a resource the current page needs is a mistake. For navigation-level speculation across a whole document, the modern successor is the Speculation Rules API, which dedupes and can prerender rather than merely warming a subresource cache.
rel="modulepreload" is a same-navigation instruction specialised for the ES module graph. Unlike a plain as="script" preload, it fetches the module, parses it, compiles it, and populates the module map — which lets the browser discover and speculatively fetch that module’s static imports without waiting for the main script to parse. It is the correct tool whenever the resource is an ES module, and it is the foundation for flattening dynamic import waterfalls.
The decision matrix
Read this top to bottom: the first row that matches your resource is your answer. The dimensions are chosen so that the cost-of-misuse column tells you what you pay for getting it wrong.
| Dimension | preload |
prefetch |
modulepreload |
|---|---|---|---|
| Intended navigation | Current page | A likely next page | Current page |
| Fetch timing | Immediately, in priority order | Idle time only | Immediately, in priority order |
| Priority band | Derived from as (Highest → Low) |
Lowest | Derived from module context (typically High) |
| Cache destination | In-memory preload cache | HTTP disk cache | Module map + memory cache |
| Parses / compiles the resource | No (bytes only) | No | Yes (module parsed, map populated) |
| Discovers child dependencies | No | No | Yes (static imports fetched) |
| CORS mode | Must match eventual request | Must match eventual request | Always CORS (crossorigin implied) |
| Applicable resource types | Any (as required) |
Any | ES modules only |
| Cost of misuse | Unused-preload warning; bandwidth on the wrong asset | Wasted download if navigation never happens | No graph benefit if used on a non-module; double-fetch on CORS mismatch |
The single most useful line in that table is the priority band. A preload and a prefetch of the identical file behave nothing alike: the preload contends with your stylesheet for bandwidth right now, while the prefetch waits politely for the network to go quiet. Choosing between them is really choosing which navigation is paying for the bytes.
A symptom-based chooser
When the matrix feels abstract, match your situation to one of these:
Minimal reproduction
All three hints side by side, each annotated with the scheduler decision it triggers:
<!-- Render-critical stylesheet the CURRENT page needs: preload at Highest.
Without this the browser discovers it only when the parser reaches the
<link>, delaying First Contentful Paint by the discovery gap. -->
<link rel="preload" as="style" href="/css/app.css">
<!-- ES module the CURRENT page runs: modulepreload so the module map is
populated and its static imports fetch in parallel, not after parse. -->
<link rel="modulepreload" href="/js/router.mjs">
<!-- Asset for the LIKELY NEXT page: prefetch at Lowest, fetched only when the
network is idle so it never steals bandwidth from the two hints above. -->
<link rel="prefetch" href="/js/checkout-chunk.mjs">
The ordering in source does not matter — the browser schedules by priority, not document order — but the as and crossorigin attributes do. Drop the as on the preload and the browser cannot assign a priority, so it either warns and ignores the hint or fetches at a default low band.
Deterministic selection protocol
Audit each hint you emit against this checklist. Any box you cannot tick means the hint is on the wrong resource.
- [ ] 1. Classify the navigation. For each candidate resource, write down whether the current page renders it or a future page might. Current-page resources are
preload/modulepreloadcandidates; future-page resources areprefetch/speculation candidates. - [ ] 2. Split modules from everything else. For each current-page resource, check whether it is an ES module (
type="module",.mjs, or bundler module output). Modules getmodulepreload; everything else getspreloadwith an explicitas. - [ ] 3. Assign
asfor every preload. Confirm eachpreloadhas anasvalue. In the Network panel, verify the Priority column matches theasband (style → Highest, font → High, image → Low). - [ ] 4. Match CORS mode. For fonts, modules, and any CORS fetch, confirm the hint’s
crossoriginattribute matches the eventual request. A mismatch produces two cache entries and a double download. - [ ] 5. Confirm prefetch does not touch the current path. In DevTools, verify every
prefetchrequest shows Lowest priority and starts only after the load event. If a prefetch fires during initial render, you have mislabelled a current-page resource. - [ ] 6. Check for unused preloads. Load the page and watch the console. An “unused preload” warning means the resource was preloaded but never consumed within the window — either the wrong hint or a stale hint after a refactor.
- [ ] 7. Re-measure LCP and idle bandwidth. Confirm the render-critical preloads improved LCP and that prefetch traffic sits entirely after the current page’s Time to Interactive.
Before/after metrics
Measured on a route with a render-critical stylesheet, one ES module entry with three static imports, and one likely next-route chunk, on a simulated 4G link (20 Mbps, 80 ms RTT). “Before” used a single rel="preload" for all four; “after” applied the matrix.
| Metric | Before (all preload) | After (matrix applied) | Change |
|---|---|---|---|
| Largest Contentful Paint | 2.9 s | 2.1 s | −0.8 s |
| Module import waterfall depth | 3 round trips | 1 round trip | −2 RTT |
| Unused-preload warnings | 1 (next-route chunk) | 0 | −1 |
| Idle bandwidth stolen from current path | 180 KB | 0 KB | −180 KB |
| Double-fetched resources | 1 (CORS mismatch) | 0 | −1 |
The LCP win comes from the module now warming its whole graph in one hop instead of a three-level walk, and from the next-route chunk no longer contending as a same-priority preload. Moving that chunk to prefetch returned 180 KB of contested bandwidth to the render-critical path.
FAQ
Can I use rel="preload" as="script" instead of modulepreload for an ES module?
You can, but you lose the graph benefit. A preload with as="script" fetches the bytes into the memory cache but does not parse the module or populate the module map, so the browser still discovers the module’s static imports only after the main parse completes. modulepreload fetches, parses, and pre-populates the module map, letting the browser speculatively fetch the imported dependencies in parallel. For an ES module, modulepreload is almost always the correct hint.
Why does my prefetched resource get downloaded twice?
prefetch stores the response in the HTTP cache keyed by URL and cache mode, but the eventual navigation may request the same URL with a different credentials or destination context, producing a cache miss and a second fetch. The most common cause is a CORS mismatch: a prefetch without crossorigin and a later CORS fetch key to different cache entries. Match the crossorigin attribute on the hint to the eventual request, exactly as you would for a preload.
Does prefetch compete with the current page’s critical requests?
No, and that is the point. prefetch is dispatched at the Lowest priority and the browser schedules it during idle time, after the current navigation’s render-critical work has drained the priority queue. That is exactly why prefetch is wrong for anything the current page needs — it will be starved behind higher-priority work until the network goes quiet.
Related
- Mastering
link rel=preloadandprefetch— the full attribute reference and priority mapping behind this decision - modulepreload & ES Module Loading — how the module hint populates the module map and warms the dependency graph
- When to Use Preload vs Prefetch for Images — the image-specific version of this choice