Fixing Vendor Chunk Duplication with manualChunks

When the same vendor package — most often react, react-dom, or a date library — is emitted into two or more chunks, every route that pulls it pays the download twice and the singleton breaks at runtime. This guide diagnoses the duplication and fixes it with build.rollupOptions.output.manualChunks in both function and object form plus resolve.dedupe. It is a focused companion to Code Splitting Strategies for Large Applications, which covers the broader chunk-boundary design this fix slots into.

Duplicated React before and a single shared vendor chunk after Before, two route chunks each inline their own copy of React; after, both reference one shared vendor-react chunk. Before: duplicated After: deduped route-a.js + react copy route-b.js + react copy react x2 2 versions route-a.js route-b.js vendor-react single copy resolve.dedupe + manualChunks collapses both copies into one hashed chunk
Figure: duplicated React across route chunks collapsed into one shared vendor-react chunk via dedupe and manualChunks.

Problem Scope

Two distinct chunks each contain a full copy of a vendor package. The symptoms are an oversized total bundle, a vendor treemap block that appears more than once, and — for stateful singletons like React — runtime errors such as Invalid hook call or two React instances disagreeing about context. For the reachability rules that govern what lands in a chunk at all, see Core Concepts of Modern Bundling.

Prerequisites & Reproducible Setup

# Vite 5.x / Rollup 4.x, Node 20+
npm i -D rollup-plugin-visualizer@5
# Inspect the dependency tree for multiple resolved copies:
npm ls react react-dom

If npm ls react prints React at two different paths (for example a hoisted root copy and one nested under a UI library), that resolution split is the most common root cause of duplication. A pnpm or workspace setup makes this more likely because nested node_modules are not always hoisted.

Diagnosis Workflow

  1. Build with the visualizer. Add rollup-plugin-visualizer and rebuild. Open dist/stats.html and search for react-dom. If the treemap shows it inside two chunks, duplication is confirmed.

    // vite.config.ts — Vite 5.x / Rollup 4.x
    import { defineConfig } from 'vite';
    import { visualizer } from 'rollup-plugin-visualizer';
    
    export default defineConfig({
      plugins: [
        visualizer({ template: 'treemap', gzipSize: true, filename: 'dist/stats.html' }),
      ],
    });
  2. Grep the emitted chunks. A reliable, CI-friendly check counts how many chunks embed a React-internal marker. More than one means duplication.

    # Vite 5.x — expect exactly 1
    grep -lR "react-dom.production" dist/assets/*.js | wc -l
  3. Check for multiple resolved versions. Run npm ls react. Two paths means the duplication is a resolution problem that manualChunks alone cannot fix — you also need resolve.dedupe (or a workspace override) so both importers resolve to the same module instance.

  4. Inspect inconsistent chunk naming. If your manualChunks function returns different names for the same package across builds (for example keying on a path segment that varies), Rollup emits separate chunks. Make the mapping deterministic.

The Solution Config

The fix has two parts: force a single resolved copy with resolve.dedupe, then place that copy into one named chunk with manualChunks. Both forms are shown.

// vite.config.ts — Vite 5.x / Rollup 4.x — function form (recommended)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  resolve: {
    // Collapse all importers onto one physical react/react-dom copy.
    dedupe: ['react', 'react-dom'],
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (!id.includes('node_modules')) return;
          // Match the react family on a path boundary so 'react' does not
          // also catch 'react-router' or 'preact' by substring.
          if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
            return 'vendor-react';
          }
          return 'vendor';
        },
      },
    },
  },
});

The path-boundary regex matters: a naive id.includes('react') also matches react-router, react-dom, and even preact, which can scatter the family across mismatched chunks and reintroduce duplication. Anchoring on [\\/]node_modules[\\/] ensures you match the package directory, not an arbitrary substring.

If you prefer zero per-id logic, the object form is fully deterministic. It maps a chunk name to the exact module specifiers, so there is no risk of an inconsistent return value:

// vite.config.ts — Vite 5.x / Rollup 4.x — object form
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: { dedupe: ['react', 'react-dom'] },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Every listed specifier resolves into exactly one chunk.
          'vendor-react': ['react', 'react-dom', 'react/jsx-runtime', 'scheduler'],
        },
      },
    },
  },
});

For a standalone Rollup build, dedupe is handled by @rollup/plugin-node-resolve with dedupe, and the same output option applies:

// rollup.config.js — Rollup 4.x
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/main.tsx',
  output: {
    dir: 'dist',
    format: 'es',
    chunkFileNames: '[name]-[hash].js',
    manualChunks(id) {
      if (/[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/.test(id)) {
        return 'vendor-react';
      }
      return undefined;
    },
  },
  plugins: [
    nodeResolve({ dedupe: ['react', 'react-dom'] }),
    commonjs(),
  ],
};

Verification

Rebuild and re-run the grep from the diagnosis step — it must now print 1. Then diff the emitted chunk list against the pre-fix build to confirm the duplicate vendor chunk is gone and route chunks shrank:

# Vite 5.x — normalize hashes and diff the chunk set
npx vite build >/dev/null 2>&1
ls -1 dist/assets/*.js | sed 's/-[a-z0-9]\{8\}\.js$/.js/' | sort > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt
# Confirm a single React copy:
grep -lR "react-dom.production" dist/assets/*.js | wc -l   # expect 1

Re-open dist/stats.html and confirm react-dom now appears in exactly one vendor-react block. If you hit an Invalid hook call at runtime before the fix, it should be gone once a single copy ships.

Gotchas & Edge Cases

dedupe without manualChunks is not enough

resolve.dedupe collapses the module instance but Rollup can still place that single copy alongside an entry if nothing else references it across chunks. Pair dedupe with a manualChunks rule that names a dedicated chunk so the copy is hoisted out of every route.

Substring matching scatters the family

As noted, id.includes('react') is the most common self-inflicted cause. react, react-dom, react/jsx-runtime, and scheduler must all land in the same chunk or React’s internals split across files. Use a path-boundary regex or the object form.

Workspace/pnpm phantom copies

In a monorepo, a package and the root can resolve different React versions. resolve.dedupe fixes the build, but also pin React as a single version via a workspace overrides/resolutions field so installs cannot regress.

Tree-shaken-away named chunks

If you list a package in the object form that nothing imports, the named chunk silently vanishes — placement never overrides reachability. Confirm the package is actually used before assuming the chunk is missing due to a config error. See Tree-Shaking Mechanics and Dead Code Elimination.