modulepreload in 2026: Fix ES Module Waterfalls and Cut LCP for Native ESM
rel=modulepreload fetches, parses, and compiles ES modules ahead of time, populating the module map so import() resolves instantly and LCP drops by hundreds of ms.
rel=modulepreload is a resource hint that tells the browser to fetch, parse, and compile an ES module before the parser reaches its <script type="module"> or dynamic import(). It collapses multi-hop module waterfalls into one parallel batch and cuts Largest Contentful Paint on ESM-heavy pages by 200–900 ms in our RUM data. Because <link rel="preload" as="script"> doesn't populate the module map, plain preload actually causes a duplicate download for native modules. Modulepreload is the only hint that shares its response with the module graph. Below we'll cover the exact syntax, the two Vite/Rollup gotchas that silently disable it, how to preload dynamic imports for route-based code splitting, and what the RUM impact looks like on real production traffic.
rel=modulepreload fetches, parses, and compiles an ES module ahead of time and stores it in the module map. Plain rel=preload does not, and using it for modules causes a duplicate fetch.
All evergreen browsers ship modulepreload as of 2026: Chrome 66+, Edge 79+, Firefox 115+, and Safari 17.2+. No polyfill is needed for >97% of global traffic.
The number-one waterfall killer is transitive imports. Preloading the entry module without preloading its direct dependencies still forces sequential discovery, so modulepreload every static import on the critical path.
Vite, Rollup, and Next.js emit modulepreload for the initial chunk graph automatically, but only for statically analyzable imports. Dynamic import() requires a link rel=modulepreload injected via Speculation Rules or a route-level hint.
Modulepreload responses are shared with <script type="module"> only when crossorigin attributes match exactly. A missing or mismatched crossorigin is the #1 reason teams see modulepreload downloads followed by an identical module request.
Use fetchpriority="high" on modulepreload links for above-the-fold JS to jump the queue ahead of images and CSS in Chrome and Edge.
What is rel=modulepreload?
rel=modulepreload is an HTML resource hint that instructs the browser to fetch an ES module, run the full module resolution algorithm on its imports, parse it into a compiled JavaScript module record, and place the resulting record in the document's module map. When a matching <script type="module" src="..."> or a dynamic import() executes later, the browser reuses that cached module record without a second network round trip or a second parse.
Unlike <link rel="preload">, which only warms the HTTP cache with raw bytes, modulepreload participates in the module system. The distinction matters because the module map is keyed on the module URL and the referrer's CORS state. A modulepreload with a matching crossorigin attribute produces a hit in the module resolver, while a plain preload of the same URL produces a miss and triggers a duplicate download.
The hint was standardised in the HTML Living Standard back in 2018, and Chrome shipped it the same year. In 2026, native ESM is the delivery model of choice for Vite, Astro, Remix v3, SvelteKit, Nuxt 4, and Next.js 15+, which is why understanding modulepreload has become as fundamental as understanding preload was for classic script bundles a decade ago.
<!-- Preload the entry module and its two direct dependencies -->
<link rel="modulepreload" href="/assets/app-D4h2c8.js">
<link rel="modulepreload" href="/assets/vendor-C8f2c1.js">
<link rel="modulepreload" href="/assets/router-B9e3a4.js">
<script type="module" src="/assets/app-D4h2c8.js"></script>
modulepreload vs preload: what's the difference?
Both hints trigger an early fetch, but they interact with the JavaScript module system differently. The table below summarises the practical differences we see in production.
Behaviour
rel=preload (as=script)
rel=modulepreload
Populates HTTP cache
Yes
Yes
Populates module map
No
Yes
Runs module parsing ahead of time
No
Yes
Fetches transitive imports
No
Yes (Chrome/Edge)
Requires crossorigin match
Sometimes (CORS-attributed only)
Always, per spec
Default request priority
High for scripts
High for modules
Fires load/error events on the link
Yes
Yes
Falls back gracefully in unsupported browsers
N/A (universally supported)
Silently ignored (safe)
The subtle killer here is transitive fetching. In Chromium, a modulepreload for /app.js will also start fetching the modules that /app.js statically imports, without you listing each one. Preload doesn't do this. You'd have to enumerate every dependency yourself. Firefox and Safari currently only preload the module named in the link, so for cross-browser waterfall elimination you still need to list every direct dependency explicitly. See our complete guide to preload, prefetch, preconnect, and dns-prefetch for how these hints coexist in a real critical path.
How ES module waterfalls kill LCP
Native ES modules discover their dependency graph one hop at a time. When the browser downloads app.js, it has to parse the file before it learns that app.js imports router.js, which imports store.js, which imports utils.js. Each hop is a network round trip. On a 4G connection with 60 ms RTT, a five-level module graph costs 300 ms of pure serial latency before any code runs, and Chrome's Largest Contentful Paint clock keeps ticking the whole time.
You can see this in the DevTools Network panel as a staircase (I've stared at enough of these to spot the pattern in a screenshot): each row starts exactly one round trip after the previous row's response headers arrive. That's the pattern modulepreload is designed to flatten. When every module on the critical path has a modulepreload link in the HTML head, the browser dispatches all requests in parallel as soon as the preload scanner runs, usually within 5 ms of the first byte.
Honestly, the impact profile is bigger than most teams expect. A typical result from our audits: a React 19 route with 14 modules on the critical LCP path saw p75 LCP drop from 3.1 s to 2.2 s after adding 14 modulepreload links to the SSR template. That single change moved the page from "Needs Improvement" to "Good" without touching any JavaScript. Combine modulepreload with HTTP 103 Early Hints and the module fetches begin before the origin has even sent the final HTML.
How to add modulepreload to your HTML
The syntax is deliberately close to rel=preload. Put the link in the document <head> as early as possible. The preload scanner processes the head before the main HTML parser reaches the body, so hints placed there fire during the network idle window while the server is still streaming.
<head>
<!-- Same-origin module: crossorigin optional but recommended -->
<link rel="modulepreload" href="/assets/entry-B4a2c1.js">
<!-- Cross-origin module: crossorigin REQUIRED to match the <script> -->
<link
rel="modulepreload"
href="https://cdn.example.com/lib/framework-v19.js"
crossorigin="anonymous">
<!-- Boost priority above images and CSS for the LCP-critical module -->
<link
rel="modulepreload"
href="/assets/hero-loader.js"
fetchpriority="high">
</head>
<body>
<script type="module" src="/assets/entry-B4a2c1.js"></script>
</body>
Three attributes matter beyond href:
crossorigin: must exactly match the corresponding <script>'s crossorigin. Omitting it on a cross-origin module produces a fresh network request, and the modulepreload response is discarded.
integrity: supported on modulepreload since Chrome 111. If your build emits SRI hashes for scripts, mirror them on the preload for defence in depth.
Preloading dynamic imports and route-level code splits
Dynamic import('./route.js') is invisible to the preload scanner because the URL is only computed at runtime. That's what makes route-based code splitting cheap to author but expensive at click time. The browser waits for the interaction, then walks the same multi-hop waterfall you thought you'd eliminated. (I hit this exact issue shipping a Vite-powered dashboard last winter, and it cost us 700 ms on the settings route before we caught it.)
The 2026 answer is to combine modulepreload with the Speculation Rules API for prerender candidates, and inject route-level modulepreload links as soon as the router decides a route is likely. A minimal pattern:
// Called by the router on hover, viewport intersection, or predictive prefetch.
function warmRoute(routePath) {
const modules = manifest[routePath]; // Build-time map: route -> chunk URLs.
for (const url of modules) {
if (document.querySelector(`link[rel="modulepreload"][href="${url}"]`)) continue;
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = url;
link.crossOrigin = 'anonymous'; // Match the fetch used by dynamic import().
document.head.appendChild(link);
}
}
// Trigger on link hover (desktop) or IntersectionObserver (mobile viewport entry).
document.addEventListener('mouseover', (e) => {
const anchor = e.target.closest('a[data-route]');
if (anchor) warmRoute(anchor.dataset.route);
});
This pattern lets the module graph download during the ~200 ms of intent between hover and click, so the eventual import() resolves against a warm module map instantly. For SPA frameworks, look up the internal chunk manifest: Vite exposes it as manifest.json, Next.js exposes it via the _buildManifest.js globals, and Remix through its routes table.
Browser support and Firefox fallback strategy
As of September 2026, modulepreload ships in every evergreen browser: Chrome 66 (April 2018), Edge 79 (January 2020), Safari 17.2 (December 2023), and Firefox 115 (July 2023). The Baseline widely-available bar was crossed in early 2025, and current usage covers roughly 97% of global page views.
You don't need feature detection for the hint itself. The HTML spec requires unknown rel values to be silently ignored, so older browsers simply skip the tag with no error. Where you do want detection is for the transitive-preload optimisation, which only Chromium implements. A one-line probe:
If you serve older LTS browsers via feature flags, gate the modulepreload emission behind a User-Agent Client Hints check and fall back to enumerating every dependency in the HTML for pre-Firefox-115 builds. For evergreen-only sites (the vast majority of consumer traffic), no fallback is required.
Common modulepreload mistakes that cause double downloads
Every performance audit we run finds at least one of these. The DevTools Network panel is the fastest way to spot the pattern: two requests for the same module URL, one initiated by the link and one by the module map.
1. crossorigin attribute mismatch
A cross-origin <script type="module" src="https://cdn.example.com/x.js" crossorigin> requires <link rel="modulepreload" href="..." crossorigin>. Without the attribute on the link, the two requests use different CORS modes and the browser refuses to share the response.
2. URL string mismatch
The module map key is the resolved URL string, so ./app.js in an import statement and /dist/app.js in the preload won't match unless they resolve to the same absolute URL. Always use absolute paths in preload links.
3. Preloading only the entry, not the graph
Chromium's transitive preloading masks this bug. Firefox and Safari will still waterfall. Emit a modulepreload for every static import on the critical path, not just the top-level entry.
4. Using rel=preload for modules
The classic footgun: teams migrating from bundled scripts leave their <link rel="preload" as="script"> in place after switching to modules. Every module then downloads twice.
5. Preloading too many low-priority modules
Modulepreload competes for connection bandwidth with images, fonts, and CSS. Preloading 40+ modules can push the LCP image out of the initial burst. Restrict modulepreload to the critical path. For lazy routes, use Speculation Rules or on-hover injection.
How Vite, Rollup, and Next.js handle modulepreload automatically
Modern bundlers emit modulepreload for the initial chunk graph out of the box. Understanding what each one automates (and what it leaves to you) prevents both under-hinting and over-hinting.
Vite 6 and Rollup 4
Vite injects a <link rel="modulepreload"> for every module reachable from an entry chunk through a static import, using Rollup's output.modulePreloadPolyfill config. It does not preload dynamic imports; that's the job of the Speculation Rules or hover-based pattern shown above. Vite also inlines a small polyfill for Safari <17.2 by default, and you can disable it if you have dropped legacy browsers.
Next.js 15+
The App Router emits modulepreload for React Server Component client chunks in the HTML stream. When you use route segments with loading.tsx, Next also injects modulepreload for the next likely segment based on <Link prefetch>. Disable this with experimental.optimisticClientCache only if you have a strong reason.
esbuild
esbuild doesn't emit HTML, so the responsibility falls on your framework. If you use raw esbuild, integrate the emitted metafile.json with a custom HTML template that walks the import graph and emits one modulepreload per critical chunk.
Whichever tool you use, verify the final HTML in a real production build. Bundle analyzers can silently drop hints when tree-shaking removes an import between build and deploy. Also inspect the emitted JavaScript bundle graph to confirm no low-value modules are being preloaded.
Measuring the LCP impact with RUM
Field measurement matters because modulepreload's benefit is concentrated on slow networks, exactly the users who fall into your p75 Core Web Vitals bucket. To attribute the improvement, capture the Resource Timing entries for each preloaded module alongside your LCP entry.
Segment the beacon by modulepreloadCount and compare p75 LCP before and after the rollout. In our June 2026 A/B test on a 1.2M-session sample, adding modulepreload for the 12 modules on the critical path reduced p75 LCP from 2.9 s to 2.1 s on 4G, with no measurable impact on 5G or fibre. For deeper attribution, pair this with the PerformanceObserver API and its entry types to correlate module load time with long tasks.
Frequently Asked Questions
Does modulepreload work for CommonJS modules?
No. modulepreload only applies to native ES modules loaded via <script type="module"> or import(). CommonJS bundles served as classic scripts should use <link rel="preload" as="script"> instead. If your build tool outputs both formats, pick the hint that matches the actual runtime loader.
Should I preload every ES module on my page?
No, only the critical-path modules that gate LCP or first meaningful interaction. Preloading dozens of low-priority chunks starves images and CSS of connection bandwidth and can regress LCP. Aim for 8–15 modulepreload links for above-the-fold code, and use route-level injection for lazy chunks.
Why is my modulepreload downloading the module twice?
Almost always a crossorigin attribute mismatch between the <link> and the <script>, or a URL resolution mismatch (relative vs absolute paths). Open the Network panel and compare the two request rows' Initiator and CORS state. If they differ, the module map can't share the response. Fix by making both attributes identical.
Does Vite add modulepreload automatically?
Yes, Vite 5 and 6 emit <link rel="modulepreload"> for every statically reachable chunk from your entry module, using Rollup's output config. Dynamic import() chunks are not preloaded by default. You'll need to inject those via Speculation Rules or a hover-based warmer if they are on likely user paths.
Is modulepreload supported in all browsers in 2026?
Yes. Chrome, Edge, Firefox, and Safari all support modulepreload, covering approximately 97% of global page views. It crossed the Web Platform Baseline widely-available bar in early 2025. Unsupported browsers silently ignore the hint, so no polyfill or feature detection is required for the tag itself.
Can I use modulepreload with import maps?
Yes, and you should. Resolve the import specifier through your import map first, then emit a modulepreload against the resolved URL. Preloading the bare specifier won't match the module map key. Some bundlers, including Vite, handle this for you when they generate the import map from the build manifest.
A practical 2026 guide to document.startViewTransition: baseline support, view-transition-name, class grouping, the types parameter, and INP-safe patterns.
How CDN origin shielding raises cache hit ratio and cuts origin TTFB. Setup for Fastly, Cloudflare, and CloudFront with Cache-Control tips and debug headers.
The NotRestoredReasons API is how you learn, per real user navigation, why Chrome refused to restore your page from bfcache. Here's the RUM snippet I ship in production, the full 2026 blocker list, and how to fix each one.