Configuring Vite manualChunks for Vendor Isolation

Symptom: after running vite build on a mid-size React SPA, dist/assets/ contains a single vendor-[hash].js file of 1.4 MB. The browser Network waterfall shows this file blocking the critical rendering path, driving Largest Contentful Paint above 3 s. Deploying a minor feature update β€” one that does not touch any third-party dependency β€” still busts the vendor hash and forces all repeat visitors to re-download the full 1.4 MB.

dist/assets/
  index-BcDeFgHi.js       22 KB   ← app code
  vendor-AaAbBbCc.js    1 432 KB  ← every node_modules dep merged together

This is the default Rollup chunking behaviour that Vite inherits. Fixing it requires an explicit manualChunks configuration inside build.rollupOptions.output.


Root Cause: How Rollup Decides What Goes in a Shared Chunk

Vite delegates bundle generation to Rollup. Without a manualChunks override, Rollup applies its module promotion heuristic: any module imported by more than one entry point or async chunk is automatically promoted to a shared chunk. Because most node_modules are imported from both the main entry and one or more lazy routes, they all meet the threshold and collapse into one file.

This behaviour is documented in the vendor chunk isolation and third-party management approach, which sits within the wider route-based code splitting and dynamic import strategies architecture. The fix is to supply a manualChunks function that overrides module-promotion decisions with explicit semantic groupings before Rollup evaluates its automatic heuristics.

The diagram below shows what changes between the two states:

manualChunks: monolithic vendor chunk vs isolated vendor chunks Left side shows a single large vendor chunk containing React, lodash-es, axios, and graphql merged together. Right side shows four separate chunks: react-vendor, utils-vendor, network-vendor, and shared-runtime, each with a stable hash that only changes when that group of dependencies is updated. BEFORE β€” no manualChunks vendor-AaAbBbCc.js 1 432 KB Β· hash busts on any dep change react + react-dom + scheduler lodash-es + date-fns axios + graphql tslib Β· @babel/runtime Β· core-js manualChunks AFTER β€” isolated chunks react-vendor-[hash].js react Β· react-dom Β· scheduler β€” 148 KB utils-vendor-[hash].js lodash-es Β· date-fns β€” 210 KB network-vendor-[hash].js axios Β· graphql β€” 96 KB shared-runtime-[hash].js tslib Β· @babel/runtime Β· core-js β€” 38 KB

Exact Configuration Fix

Add the manualChunks function to vite.config.ts. The function receives the resolved module ID β€” an absolute filesystem path β€” and returns a string chunk name or undefined to let Rollup decide.

// vite.config.ts β€” Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          if (!id.includes('node_modules')) return; // only split third-party code

          // 1. Shared runtime helpers β€” resolve first to prevent duplication
          const sharedRuntime = ['tslib', '@babel/runtime', 'core-js'];
          if (sharedRuntime.some(dep => id.includes(`/node_modules/${dep}/`))) {
            return 'shared-runtime';
          }

          // 2. React ecosystem β€” stable across minor app releases
          if (
            id.includes('/node_modules/react/') ||
            id.includes('/node_modules/react-dom/') ||
            id.includes('/node_modules/scheduler/')
          ) {
            return 'react-vendor';
          }

          // 3. Utility libraries β€” change on package bumps, not feature work
          if (
            id.includes('/node_modules/lodash') ||
            id.includes('/node_modules/date-fns/')
          ) {
            return 'utils-vendor';
          }

          // 4. Network / data layer
          if (
            id.includes('/node_modules/axios/') ||
            id.includes('/node_modules/graphql/')
          ) {
            return 'network-vendor';
          }

          // 5. Catch-all for remaining node_modules
          return 'vendor';
        },
        // Deterministic filename pattern β€” required for long-term cache headers
        chunkFileNames: 'assets/[name]-[hash].js',
      },
    },
  },
});

Key points in the ordering:

  • The sharedRuntime guard runs first so that tslib (a transitive dependency of both date-fns and axios) always resolves to one chunk regardless of which rule would otherwise claim it.
  • Path matching uses the full /node_modules/<package>/ segment with surrounding slashes to avoid false matches (e.g. react-query matching a bare react test).
  • Returning undefined for non-node_modules paths preserves Rollup’s default app-code chunking, including lazy-route boundaries produced by dynamic import patterns for on-demand loading.

Step-by-Step Verification

  1. Run the build and inspect the output directory.

    npx vite build
    ls -lh dist/assets/*.js

    Expected output:

    dist/assets/
      index-BcDeFgHi.js          22 KB
      react-vendor-XxYyZz.js    148 KB
      utils-vendor-AaBbCc.js    210 KB
      network-vendor-DdEeFf.js   96 KB
      shared-runtime-GgHhIi.js   38 KB
      vendor-JjKkLl.js           54 KB   ← catch-all remainder
    

    If any file exceeds 400 KB, narrow its manualChunks rule to split the group further.

  2. Confirm zero duplicate-module warnings.

    Rollup prints [!] (!) Some chunks are exceeding the assets size limit or duplicate-module messages to stderr. If you see them, a shared sub-dependency is being claimed by two rules β€” add it to the sharedRuntime array.

  3. Verify chunk composition with source-map-explorer.

    npx source-map-explorer dist/assets/react-vendor-*.js

    The interactive treemap must show only react, react-dom, and scheduler β€” not lodash-es or axios. Repeat for each named chunk.

  4. Check browser Network tab on a repeat visit.

    • Load the app in Chrome DevTools (Network panel, disable cache OFF for the second visit).
    • Deploy a feature change that modifies only app code.
    • Reload: only index-[hash].js should show 200; all vendor chunks should show 304 or be served from (disk cache). The vendor hashes must not have changed.
  5. Validate Cache-Control headers are set to immutable.

    curl -I https://your-domain.com/assets/react-vendor-XxYyZz.js | grep -i cache-control
    # Expected: Cache-Control: public, max-age=31536000, immutable

    If the header is missing, configure your CDN or static host to add immutable to all /assets/*.js responses (Vite’s dev server does not emit this; it must be set at the infrastructure layer).

  6. Automate the check in CI.

    # Enforce vendor chunk count and size limits
    node -e "
      const fs = require('fs');
      const assets = fs.readdirSync('dist/assets').filter(f => f.endsWith('.js'));
      const vendor = assets.filter(f => /^(react-vendor|utils-vendor|network-vendor|shared-runtime|vendor)-/.test(f));
      vendor.forEach(f => {
        const kb = Math.round(fs.statSync('dist/assets/' + f).size / 1024);
        if (kb > 400) throw new Error(f + ' exceeds 400 KB: ' + kb + ' KB');
      });
      if (vendor.length > 6) throw new Error('Too many vendor chunks: ' + vendor.length);
      console.log('Vendor chunks OK:', vendor.length, 'chunks');
    "

Edge Cases and Gotchas

Gotcha 1 β€” Monorepo workspace packages incorrectly treated as vendors

In a monorepo, internal packages resolve through node_modules/@my-org/shared-utils, which means a bare id.includes('node_modules') check will assign them to the vendor catch-all. Those packages change with every commit, so giving them an immutable-cache label is wrong.

Fix: add a workspace namespace guard before the vendor rules:

// vite.config.ts β€” Vite 5+ (monorepo variant)
manualChunks(id: string) {
  if (id.includes('/node_modules/@my-org/')) return; // treat as app code
  if (!id.includes('node_modules')) return;
  // … rest of the rules unchanged …
}

Gotcha 2 β€” Over-segmentation duplicates sub-dependencies

Splitting every package family into its own chunk (e.g. a separate chunk for zod, a separate chunk for immer, a separate chunk for clsx) causes Rollup to inline any transitive helpers not yet claimed by a rule. The result is the same 10 KB helper appearing in five chunks β€” increasing total parse time rather than reducing it.

Diagnosis: run npx vite build --debug and search the output for Generated chunks. If you see the same module path listed under multiple chunk names, your rules are too granular.

Fix: merge low-traffic packages into the catch-all vendor group. A good rule of thumb: only isolate packages that exceed 80 KB individually or that are updated on a cadence independent of all other packages in the group.

Gotcha 3 β€” SSR / Nuxt / SvelteKit builds ignore client manualChunks

Server-side rendering frameworks run a second Rollup pass for the server bundle. The build.rollupOptions.output.manualChunks key in the Vite config applies only to the client build; the server build externalises most node_modules and does not generate vendor chunks. Defining manualChunks in the wrong configuration block causes a build error in Nuxt 3 (manualChunks is not a function).

Fix for Nuxt 3:

// nuxt.config.ts β€” Vite 5+ (client-only manualChunks)
export default defineNuxtConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks(id: string) {
            if (!id.includes('node_modules')) return;
            if (id.includes('/node_modules/vue/') || id.includes('/node_modules/@vue/')) {
              return 'vue-vendor';
            }
            return 'vendor';
          },
        },
      },
    },
  },
});

Do not add the same configuration under nitro.rollupConfig β€” that targets the server bundle and manualChunks has no meaningful effect there.


FAQ

Why does Vite produce one giant vendor chunk by default?

Rollup’s default heuristic merges all node_modules imports that appear in more than one chunk into a single shared chunk to maximise HTTP/2 multiplexing. Without an explicit manualChunks function there is no semantic boundary between a UI framework and a utility library β€” they all land together.

Can manualChunks break tree-shaking?

Yes. Assigning an entire package family (e.g. every /node_modules/lodash-es/ path) to a named chunk forces Rollup to emit the whole package rather than only the exports that are statically imported. Scope a manualChunks rule to packages that are already fully imported, or pair it with "sideEffects": false in the dependency’s package.json to keep tree-shaking active.

How do I prevent tslib from being duplicated across vendor chunks?

Add a high-priority shared-runtime group at the top of your manualChunks function that matches tslib, @babel/runtime, and core-js before any other rule runs. This guarantees a single shared-runtime-[hash].js chunk that every other vendor chunk references.