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:

Hint selection flowchart 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 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.
  • [ ] 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 — 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