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.

That difference is worth drawing, because it is the only one of the three columns in the matrix below that changes the shape of the request graph rather than just its priority. A plain preload of router.mjs buys you one hop; a modulepreload on every level of the graph buys you the whole graph in one hop:

A three-level module graph fetched serially over three round trips with a script preload, versus all three levels fetched together in one round trip with modulepreload On the left, a chain of three modules: router.mjs is fetched at round trip one, routes.mjs is only discovered at round trip two, and view.mjs at round trip three, each step costing another eighty milliseconds. On the right, three modulepreload hints in the head populate the module map before parse, so router.mjs, routes.mjs and view.mjs are all requested in the same round trip. The chain costs three round trips before view.mjs starts; the hinted version costs one. Why modulepreload flattens the import walk Same three-level module graph on an 80 ms RTT link: discovery cost with and without the module hint preload as=script: serial discovery modulepreload on all three: one hop router.mjs fetched at RTT 1 routes.mjs discovered at RTT 2 view.mjs discovered at RTT 3 +80 ms +80 ms 3 round trips before view.mjs starts 3 modulepreload hints in the head module map populated before parse router.mjs RTT 1 routes.mjs RTT 1 view.mjs RTT 1 1 round trip, all three in flight together graph depth stops costing round trips

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:

Decision flowchart selecting preload, prefetch, or modulepreload based on whether the resource is needed by the current navigation and whether it is an ES module A flowchart. First decision: is the resource needed by the current navigation? If no, use prefetch, dispatched at lowest priority during idle time into the HTTP cache. If yes, second decision: is it an ES module? If yes, use modulepreload, which parses the module and fetches its imports. If no, use preload with an as attribute, fetched immediately at a priority derived from as. Needed by the current navigation? No prefetch Lowest priority, idle time into the HTTP cache Yes Is it an ES module? No preload (with as) Immediate, priority from as promote images with fetchpriority Yes modulepreload Parses the module map, fetches static imports

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/modulepreload candidates; future-page resources are prefetch/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 get modulepreload; everything else gets preload with an explicit as. If a bundler owns the output, read the hint list off the build manifest rather than hand-writing it — mapping Vite chunk graphs to modulepreload shows how.
  • [ ] 3. Assign as for every preload. Confirm each preload has an as value. In the Network panel, verify the Priority column matches the as band (style → Highest, font → High, image → Low).
  • [ ] 4. Match CORS mode. For fonts, modules, and any CORS fetch, confirm the hint’s crossorigin attribute 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 prefetch request 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 — a dead hint, a stale hint left behind by a refactor, or, most often, a key mismatch between the hint and the request that was supposed to claim it.
  • [ ] 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 — visible in the waterfall as the chunk sliding out of the render window entirely:

Two stacked waterfalls of the same route showing the 180 KB next-route chunk contending during render as a preload, then moving to idle time as a prefetch In the before waterfall, app.css takes 0.70 seconds, the module entry and its three static imports take three round trips totalling 1.75 seconds, and the 180 KB next-route chunk transfers at the same time, stealing bandwidth; Largest Contentful Paint lands at 2.9 seconds. In the after waterfall, app.css takes 0.60 seconds, modulepreload collapses the module graph to one hop of 0.90 seconds, and the chunk is prefetched from 2.30 to 2.95 seconds during idle time; Largest Contentful Paint lands at 2.1 seconds. The same four resources, hinted two ways Simulated 4G: 20 Mbps, 80 ms RTT, cold cache, one next-route chunk of 180 KB Before: all four resources hinted with rel=preload app.css (preload) router.mjs + 3 imports checkout chunk (180 KB) 0.70 s 3 round trips, 1.75 s 180 KB steals bandwidth LCP 2.9 s Every hint lands in the same immediate band, so the chunk competes with the module walk. After: modulepreload flattens the graph, the chunk moves to prefetch app.css (preload) router.mjs + 3 imports checkout chunk (180 KB) 0.60 s 1 hop, 0.90 s prefetch, idle LCP 2.1 s 0 0.5 1.0 1.5 2.0 2.5 3.0 s

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