How to Control Vite's modulepreload for Dynamic Imports

This guide covers a Vite build feature that silently shapes every page load, within Vite & Rollup Build Optimization and JavaScript Bundle Optimization & Code Splitting. ES modules load as a graph: the browser fetches the entry, parses it, discovers its imports, fetches those, and so on. Without hints, a three-level import chain costs three sequential round trips — a waterfall.

Vite counters this in two ways. For the entry, it injects <link rel="modulepreload"> tags into the HTML for every statically imported chunk, so the browser fetches the whole static graph in parallel. For dynamic imports, it rewrites import('./Route.js') into a helper that first preloads the chunk's dependencies (and their CSS) and then imports it. Both usually help; both can over-fetch when chunking is fine-grained or when a dynamic import is speculative.

A three-level import chain with and without modulepreload Two timelines comparing sequential loading of an entry, a shared chunk and a route chunk with parallel loading enabled by modulepreload. A three-level import chain with and without modulepreload No preload entry.js shared.js route.js modulepreload entry + shared + route (parallel) 0ms 100ms 200ms 300ms 400ms 500ms 600ms 700ms 800ms 900ms 1000ms

Rapid Diagnosis

  • View source of the built HTML. Count <link rel="modulepreload"> tags. Dozens of them suggest fine-grained chunks being preloaded eagerly.
  • Check the Network panel's Initiator column for chunks loaded after a dynamic import: are their dependencies requested together, or one after another?
  • Look for unused preloads. Chrome warns in the console when a preloaded resource is not used within a few seconds of load.
  • Check bandwidth competition. On slow connections, many preloaded chunks can delay the LCP image or critical CSS.

Root Cause Analysis

1. Waterfalls without preload. Disabling modulePreload (or using a framework integration that does) reintroduces sequential discovery of imports.

2. Over-preloading the static graph. Every statically imported chunk is preloaded at high priority. If the entry statically imports modules that are not needed for the first render, they compete with critical resources.

3. Dependency preloads for speculative imports. A dynamic import triggered on idle or hover (prefetch-like behaviour) still preloads all its dependencies at preload priority, which is higher than you want for speculative work.

4. Missing polyfill in older browsers. modulepreload is supported in all modern engines; Vite includes a small polyfill by default for engines that lacked it, which you may not need.

Under-preloading vs over-preloading Comparison of the symptoms of too few module preloads with the symptoms of too many. Under-preloading vs over-preloading Too few preloads • Import chains load level by level • Route transitions wait on round trips • Visible as staircase in the waterfall Too many preloads • Dozens of high-priority JS requests at load • LCP image and fonts start later • Unused-preload warnings in the console

Step-by-Step Resolution

1. Keep modulepreload on, and audit what the entry imports statically

The static graph of the entry is preloaded wholesale, so make it small. Move anything not needed for the first render behind a dynamic import.

javascript
// main.ts — keep startup imports minimal.
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
// Analytics, feature flags UI, devtools panels: import() them after mount.
requestIdleCallback(() => import('./analytics'));
// trade-off: moving analytics out of the static graph delays its first events
// by a few hundred milliseconds. For most products that is acceptable; for
// attribution-critical landing pages, keep only the minimal page-view call early.

Expected outcome: fewer modulepreload links in the HTML, all for code actually needed at startup.

2. Filter dependency preloads with resolveDependencies

Vite's build.modulePreload.resolveDependencies lets you decide, per dynamic import, which dependencies get preloaded.

javascript
// vite.config.js
export default {
  build: {
    modulePreload: {
      polyfill: false,                         // modern targets support modulepreload natively
      resolveDependencies(filename, deps, { hostType }) {
        // Do not eagerly preload heavy, lazily used libraries from the HTML.
        if (hostType === 'html') return deps.filter((d) => !/chart|editor|pdf/.test(d));
        return deps;
      },
    },
  },
};
// trade-off: filtering deps means those chunks load on demand, reintroducing a
// round trip when they are needed. Filter only chunks that are genuinely not
// part of the first render.

Expected outcome: the HTML preloads only the startup-critical graph; heavy libraries load when their routes are visited.

Requests competing with the LCP image at startup Bar chart of high-priority requests issued before the LCP image request under default and tuned modulepreload settings. Requests competing with the LCP image at startup Default (large static graph) 23 req Entry slimmed 11 req + resolveDependencies filter 7 req

3. Use prefetch, not preload, for speculative chunks

For routes the user might visit next, use low-priority prefetching (on hover or viewport) rather than calling import() early, which triggers preload-priority fetches for the whole dependency set.

javascript
// Low-priority prefetch of a route chunk and its deps from the Vite manifest.
function prefetchRoute(files) {
  for (const href of files) {
    const l = document.createElement('link');
    l.rel = 'prefetch'; l.href = href; l.as = 'script';
    document.head.append(l);
  }
}
// trade-off: prefetched chunks are fetched at idle priority and may not be ready
// if the user clicks immediately. For a menu item the user is hovering, a real
// import() on hover is often the better trade.

Expected outcome: speculative work no longer competes with the current page's critical resources. Prefetching route chunks on hover and viewport covers strategies in depth.

4. Emit preloads for SSR-rendered routes

With server rendering, the server knows which route is being rendered; use the Vite manifest to emit modulepreload links for that route's chunks in the HTML head, so the browser starts them during HTML parsing rather than after the entry executes.

Expected outcome: the route chunk downloads in parallel with the entry on hard loads.

Verification

In a throttled trace, the entry and its static dependencies should start together, dynamic routes should load their dependency sets in one parallel batch, and the LCP image should start before most JavaScript preloads. The console should show no unused-preload warnings. Compare LCP and route transition times before and after; flattening a two-level chain typically saves one RTT (100–300ms on mobile) per navigation.

How Frameworks on Vite Handle This

Nuxt emits modulepreload and prefetch links for the current route and, by default, prefetches linked route chunks for <NuxtLink> components in the viewport. Tune with experimental.defaults.nuxtLink.prefetchOn and the features.inlineStyles settings.

SvelteKit preloads route code and data on hover by default (data-sveltekit-preload-data), with options for tap or viewport.

Astro ships little JavaScript by default; islands' chunks are preloaded only when their hydration directive triggers.

In each case, check the framework's own hints before adding custom modulepreload logic, or you will preload the same chunks twice.

Common Mistakes

  • Adding manual modulepreload links on top of Vite's. Duplicate hints are harmless for fetching but clutter the head and make audits harder; let the build or framework own them.
  • Preloading cross-origin chunks without crossorigin. Module scripts are fetched in CORS mode; a preload with the wrong mode is not reused.
  • Assuming preload means executed. modulepreload fetches and may compile, but evaluation happens only when the module is imported. Side-effectful modules still run at import time.

FAQ

Is modulepreload the same as preload with as="script"?

No. modulepreload fetches the module and can parse and compile it ahead of time in a module-aware way, and uses the correct credentials mode for module scripts. A plain preload as="script" for a module can result in a second fetch because of mismatched request modes. Use modulepreload for ES modules — see using modulepreload for ES module graphs.

Should I turn off the modulepreload polyfill?

If your browser targets are all modern, yes — modulepreload is supported in current Chromium, Firefox and Safari, and the polyfill adds a small inline script. Keep it only if analytics show meaningful traffic from browsers that lack support.

Does over-preloading affect INP?

Indirectly. Preloaded modules may be compiled early on the main thread or a background thread depending on the engine, and if the preloaded code is then evaluated during startup, it adds to long tasks. The bigger effect is on LCP through bandwidth competition.