Prefetch and Preload Strategies for Critical Routes

Without explicit resource hints, browsers treat dynamic import() chunks as low-priority requests deferred until the routing lifecycle triggers execution. The result is a waterfall: the user clicks a link, the router fires, the browser discovers the chunk URL, issues a fresh network request, waits for download and parse, then renders — adding 300–800 ms of perceived latency even on a fast connection. On a mid-tier 4G device, that latency gap directly degrades Interaction to Next Paint (INP) and forces route transitions to show loading spinners where none are necessary.

Prefetch and preload resource hints decouple chunk discovery from chunk execution. The browser fetches and caches route assets during idle periods, so when the user actually navigates, the chunk is already resident — the transition becomes near-instant.

Quantified Impact

  • Route transition TTFB: prefetching the next route’s primary chunk reduces perceived navigation latency by 300–600 ms on 4G and 600–1200 ms on 3G when the hint lands in cache.
  • LCP improvement: modulepreload on the above-the-fold chunk for the initial route cuts LCP by 15–30% by advancing parse and compile into the browser’s idle phase.
  • Cache hit rate: viewport-triggered prefetch achieves 40–65% cache hit rates for subsequent navigations in typical SPA navigation patterns.
  • INP risk from over-preloading: injecting more than 3 concurrent preloads per transition can delay LCP by 80–150 ms as preloaded scripts compete with critical rendering resources on HTTP/2 streams.
  • Bandwidth cost on mobile: a single speculative prefetch for a 120 KB route chunk wastes that bandwidth if the user never navigates there — network-aware gating eliminates unnecessary consumption.

Architectural Context

This page sits within the Route-Based Code Splitting & Dynamic Import Strategies section. The parent strategy establishes chunk boundaries at route definitions; this page explains how to bridge the gap between those compile-time boundaries and runtime network delivery. The vendor chunk isolation strategy is a prerequisite: if vendor code is not separated into stable, immutable chunks, speculative hints trigger redundant downloads on every deploy.

Request waterfall: without vs with prefetch

Route navigation waterfall: without prefetch vs with prefetch Two horizontal timelines showing how prefetch eliminates the chunk fetch latency on user-initiated route transitions. Without prefetch User clicks Router fires Chunk fetch Parse + compile Render ~600 ms total With prefetch (idle) Idle prefetch User clicks → render cached ~80 ms

Browser Semantics: Preload vs Prefetch Priority Queues

<link rel="preload"> and <link rel="prefetch"> are governed by the browser’s resource scheduler and HTTP stream prioritization.

Preload forces an immediate high-priority network fetch. It competes directly with critical CSS, fonts, and above-the-fold scripts on HTTP/2 streams. Misapplied preload starves primary rendering resources and delays LCP. Use preload only for chunks the current page needs for its first render.

Prefetch operates at low priority, queuing during idle time. Browsers may defer or evict prefetch requests under memory pressure. It does not guarantee execution readiness — only cache residency.

<link rel="modulepreload"> is the correct choice for ES module chunks. Unlike <link rel="preload" as="script">, modulepreload fetches, parses, and compiles the module graph, making it immediately executable when needed. Use modulepreload for chunks the current route imports, and prefetch for chunks likely-next routes will need.

Bundler-Specific Configuration

Webpack 5: magic comment directives

In Webpack 5, magic comments on dynamic imports emit <link> tags in the parent chunk’s HTML output:

// Webpack 5 — preload: high-priority, current-route critical chunk
const HeroSection = () =>
  import(/* webpackPreload: true, webpackChunkName: "hero" */ './HeroSection');

// Webpack 5 — prefetch: low-priority, likely-next-route chunk
const Dashboard = () =>
  import(/* webpackPrefetch: true, webpackChunkName: "dashboard" */ './Dashboard');

const Settings = () =>
  import(/* webpackPrefetch: true, webpackChunkName: "settings" */ './Settings');

webpackPreload causes Webpack to emit <link rel="modulepreload"> in the parent chunk’s output (Webpack 5.87+ emits modulepreload by default for ES module output; earlier versions emit preload as="script"). webpackPrefetch emits <link rel="prefetch">.

Webpack’s SplitChunksPlugin must isolate shared dependencies to prevent duplicate fetches when multiple routes are prefetched simultaneously:

// webpack.config.js — Webpack 5
module.exports = {
  output: {
    // Deterministic hashes survive unchanged modules across deploys
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        // Stable vendor chunk — survives deploys for long cache TTL
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 20,
        },
        // Route-level chunks for predictable prefetch targets
        routes: {
          test: /[\\/]src[\\/]routes[\\/]/,
          chunks: 'async',
          priority: 10,
          minSize: 20_000,
        },
      },
    },
  },
};

Vite 5+: plugin-based injection

Vite uses Rollup’s bundler pipeline and does not honour webpackPrefetch or webpackPreload comments. Vite automatically injects <link rel="modulepreload"> for all chunks referenced from the entry HTML. For explicit prefetch control over named route chunks, use the transformIndexHtml plugin hook combined with the generated manifest.json:

// vite.config.js — Vite 5+
import { defineConfig } from 'vite';
import { readFileSync } from 'fs';
import { resolve } from 'path';

export default defineConfig({
  build: {
    // Emit manifest.json so the plugin can read content-hashed filenames
    manifest: true,
    rollupOptions: {
      output: {
        // Named route chunks become predictable prefetch targets
        manualChunks(id) {
          if (id.includes('node_modules')) return 'vendor';
          if (id.includes('/routes/dashboard')) return 'dashboard';
          if (id.includes('/routes/settings')) return 'settings';
          if (id.includes('/routes/profile')) return 'profile';
        },
      },
    },
  },
  plugins: [
    {
      name: 'route-prefetch-injector',
      // Runs after the build so manifest.json is available
      apply: 'build',
      transformIndexHtml: {
        order: 'post',
        handler(html) {
          // Read the manifest produced by this build
          const manifestPath = resolve(__dirname, 'dist/.vite/manifest.json');
          let manifest;
          try {
            manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
          } catch {
            // Manifest not available during dev; skip injection
            return html;
          }

          const prefetchRoutes = ['dashboard', 'settings', 'profile'];
          const tags = prefetchRoutes.flatMap((name) => {
            const entry = manifest[`src/routes/${name}/index.tsx`];
            if (!entry) return [];
            return [{
              tag: 'link',
              attrs: { rel: 'prefetch', href: `/${entry.file}`, as: 'script' },
              injectTo: 'head',
            }];
          });

          return { html, tags };
        },
      },
    },
  ],
});

Framework Integration: React and Vue Router Hooks

React: Suspense boundaries with router-aware prefetch

The standard React lazy and Suspense pattern defers chunk loading until the route activates. Pair it with programmatic prefetch to prime the cache before activation:

// Vite 5+ / Webpack 5 — React Router 6 with hover-intent prefetch
import { lazy, Suspense } from 'react';
import { Link, useNavigate } from 'react-router-dom';

// Chunk defined at module scope — bundler sees the import statically
const DashboardRoute = lazy(
  () => import(/* webpackChunkName: "dashboard" */ './routes/Dashboard')
);

// Expose the underlying import promise for prefetch priming
const prefetchDashboard = () =>
  import(/* webpackChunkName: "dashboard" */ './routes/Dashboard');

function NavBar() {
  return (
    <nav>
      <Link
        to="/dashboard"
        onMouseEnter={prefetchDashboard}   // desktop hover intent
        onFocus={prefetchDashboard}         // keyboard navigation intent
      >
        Dashboard
      </Link>
    </nav>
  );
}

function App() {
  return (
    <Suspense fallback={<RouteShell />}>
      <DashboardRoute />
    </Suspense>
  );
}

Calling the import a second time on actual navigation is a no-op: the module registry deduplicates it and returns the already-resolved Promise.

Vue 3: defineAsyncComponent with router navigation guards

Vue 3’s defineAsyncComponent combined with Vue Router’s beforeEach guard provides programmatic prefetch on navigation intent:

// Vite 5+ / Webpack 5 — Vue Router 4 programmatic prefetch
import { defineAsyncComponent } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';

// Async component definitions
const Dashboard = defineAsyncComponent(
  () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue')
);

const Settings = defineAsyncComponent(
  () => import(/* webpackChunkName: "settings" */ './views/Settings.vue')
);

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/dashboard', component: Dashboard, meta: { prefetchGroup: 'app' } },
    { path: '/settings',  component: Settings,  meta: { prefetchGroup: 'app' } },
  ],
});

// Prefetch likely next routes on navigation start
router.beforeEach((to) => {
  // Prefetch sibling routes in the same group
  router.getRoutes()
    .filter(r => r.meta.prefetchGroup === to.meta.prefetchGroup && r.path !== to.path)
    .forEach(r => {
      const comp = r.components?.default;
      if (typeof comp === 'function') {
        // Trigger import() — the module registry deduplicates
        (comp as () => Promise<unknown>)().catch(() => { /* silently ignore prefetch failures */ });
      }
    });
});

Predictive Loading: Viewport Tracking and Network-Aware Gating

Static build-time injection does not adapt to real-time user behaviour. Use IntersectionObserver for viewport-proximity detection and gate prefetch on current network conditions:

// Vite 5+ / Webpack 5 — network-aware programmatic prefetch
const networkAllowsPrefetch = () => {
  const conn =
    (navigator as any).connection ||
    (navigator as any).mozConnection ||
    (navigator as any).webkitConnection;
  if (!conn) return true;  // Unknown connection: be optimistic
  return (
    conn.effectiveType !== '2g' &&
    conn.effectiveType !== '3g' &&
    !conn.saveData
  );
};

function schedulePrefetch(importFn: () => Promise<unknown>): void {
  if (!networkAllowsPrefetch()) return;
  // requestIdleCallback defers to true idle time; fall back to setTimeout on Safari
  const schedule =
    typeof requestIdleCallback !== 'undefined'
      ? (cb: () => void) => requestIdleCallback(cb, { timeout: 2000 })
      : (cb: () => void) => setTimeout(cb, 200);
  schedule(() => importFn().catch(() => {}));
}

// Attach to navigation links via IntersectionObserver
function observeNavLinks(links: NodeListOf<HTMLAnchorElement>): void {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const importFn = (entry.target as HTMLElement).dataset.importFn;
          if (importFn) {
            // Resolve the function from a registry in real implementations
            schedulePrefetch(routeImportRegistry[importFn]);
            observer.unobserve(entry.target);  // Prefetch once
          }
        }
      });
    },
    { threshold: 0.1, rootMargin: '200px' }  // 200 px early trigger
  );
  links.forEach((link) => observer.observe(link));
}

The Next.js-specific prefetching guide covers framework-native <Link> viewport intersection and router.prefetch() integration.

Common Pitfalls

1. preload instead of modulepreload for ES module chunks

Root cause: <link rel="preload" as="script"> fetches the bytes but does not parse or compile the module. When the route activates and import() resolves, the browser re-parses the already-cached bytes — adding 40–120 ms of compile latency that negates the prefetch benefit.

Diagnostic signal: Network tab shows the chunk served from disk cache (size: 0), but the Performance tab shows a Compile Script task blocking the main thread on navigation.

Fix: Use <link rel="modulepreload"> or rely on Webpack 5’s webpackPreload magic comment (which emits modulepreload for ES module output), and configure output.module: true in Webpack if targeting modern browsers.

2. Prefetching unsplit vendor code

Root cause: If vendor chunk isolation is not configured, vendor modules embed inside every route chunk. Prefetching route chunks then re-downloads react, react-dom, or large utility libraries with every hint.

Diagnostic signal: Each prefetched chunk exceeds 100 KB. Running webpack-bundle-analyzer reveals node_modules/ content inside route-specific chunks.

Fix: Configure SplitChunksPlugin (Webpack 5) or manualChunks (Vite) to extract vendor code before adding any prefetch hints.

3. Injecting prefetch hints before vendor chunk extraction

Root cause: Hints injected via transformIndexHtml in Vite using hardcoded filenames become stale after contenthash rotates on the next deploy, causing 404s on the prefetch request.

Diagnostic signal: Network tab shows prefetch requests returning 404 after a new deploy, with the old content-hashed filename in the URL.

Fix: Always derive prefetch hint URLs from dist/.vite/manifest.json at build time, not from hardcoded filenames. The manifest maps source paths to output filenames including the current content hash.

4. Preloading too many chunks simultaneously

Root cause: Each <link rel="preload"> competes on HTTP/2 streams with critical CSS, fonts, and the current route’s own chunks. Injecting 5+ preloads per page load shifts LCP by 80–150 ms.

Diagnostic signal: Lighthouse flags render-blocking resources or LCP regression. The Network waterfall shows preloaded chunks queued ahead of critical CSS.

Fix: Cap concurrent preloads at 3 per page; use prefetch for everything that is not needed for the current first render.

5. Prefetching on metered or slow connections

Root cause: Speculative fetches consume the user’s data quota. A 120 KB route chunk prefetched on a 3G connection wastes ~5–8% of a typical session data budget if the user never navigates there.

Diagnostic signal: Users on metered plans report increased data consumption; navigator.connection.effectiveType reads '3g' in analytics.

Fix: Gate all prefetch injection on networkAllowsPrefetch() as shown in the code above.

Verification Workflow

1. Confirm hint injection in built HTML

# Webpack 5
npx webpack --mode production
grep -E 'rel="(modulepreload|prefetch)"' dist/index.html

# Vite 5+
npx vite build
grep -E 'rel="(modulepreload|prefetch)"' dist/index.html

Expected output: one modulepreload for each statically referenced chunk and one prefetch for each annotated route.

2. Validate in Chrome DevTools

  1. Open DevTools > Network, set throttle to Fast 4G.
  2. Load the page and let it idle for 2 seconds.
  3. Filter by JS — prefetched chunks appear with type prefetch and transfer size > 0.
  4. Navigate to the prefetched route. The chunk now shows (disk cache) or transfer size 0, confirming cache hit.
  5. Open Performance > Main thread and verify no Compile Script blocking task appears on route activation (confirms modulepreload parse-ahead worked).

3. CI gate with Lighthouse

# .github/workflows/perf-ci.yml
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - name: Serve production build
        run: npx serve dist &
      - name: Run Lighthouse CI
        run: |
          npx lhci autorun
          npx lhci assert --preset=lighthouse:recommended
// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["http://localhost:3000", "http://localhost:3000/dashboard"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "interactive": ["error", { "maxNumericValue": 3500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 300 }],
        "resource-summary:script:count": ["warn", { "max": 12 }]
      }
    }
  }
}

4. Bundle stats check for duplicate modules

# Webpack 5 — detect vendor code embedded in route chunks
npx webpack-bundle-analyzer dist/stats.json
# Look for node_modules/ appearing inside dashboard.chunk.js or settings.chunk.js

# Vite 5+ — equivalent via rollup-plugin-visualizer
# Add to vite.config.js plugins: [visualizer({ open: true })]
npx vite build

FAQ

What is the difference between rel=preload and rel=prefetch for JavaScript chunks?

preload fetches a resource at high priority as part of the current navigation — use it for chunks the current page needs immediately. prefetch fetches at low priority during idle time for resources the next navigation will need. For ES module chunks, use rel=modulepreload instead of rel=preload as=script because modulepreload also parses and compiles the module graph, making it executable immediately on navigation.

Does Vite 5+ support Webpack magic comments like webpackPrefetch?

No. Vite ignores webpackPrefetch and webpackPreload comments entirely. Vite automatically injects modulepreload for all entry-referenced chunks. For explicit prefetch control, use the transformIndexHtml plugin hook combined with the generated manifest.json as shown in the Vite configuration above.

How many concurrent preloads should I inject per route transition?

Cap concurrent preloads at 3 per transition to avoid HTTP/2 stream exhaustion. Preloaded scripts compete directly with critical CSS, fonts, and the current route’s chunks. Prefetch hints are browser-discretionary and do not compete with critical resources, but excessive queuing still strains available connections on metered networks.

When should I suppress prefetch hints?

Suppress prefetch when navigator.connection.effectiveType is '2g' or '3g', or when navigator.connection.saveData is true. On constrained networks, speculative fetching consumes the user’s data budget without a guaranteed payoff if they never navigate to the prefetched route.