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.
Duplication exists because Rollup’s chunking algorithm operates on the module graph after resolution, and it treats two modules loaded from two different absolute file paths as two unrelated modules — even when both paths contain byte-identical copies of react. Rollup has no package-identity concept during graph construction; it sees only resolved module IDs. So if react resolves to node_modules/react/index.js for your application code and to node_modules/some-ui-lib/node_modules/react/index.js for a dependency, those are two nodes in the graph, and both get pulled into whichever chunks reach them. When a stateful singleton like React is copied this way, the two copies keep separate ReactCurrentDispatcher and context registries, so a component rendered by one copy cannot read a context provided by the other.
This problem is easy to miss because nothing fails at build time — the build succeeds, the app boots, and the extra weight is invisible until you open a size report or hit a hook-related crash on a specific route. It sits at the resolution-to-emit boundary of the pipeline: install resolution decides how many physical copies exist, manualChunks decides where each reachable copy is placed, and hashing decides whether the resulting chunk can be cached. Getting the fix right means acting on all three, in that order, rather than reaching for manualChunks alone and being surprised when the duplicate survives.
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.
It is worth separating the two failure modes because they need different confidence to declare fixed. The size symptom is purely additive: shipping React twice adds roughly 6–7 KB gzipped per extra copy, which is annoying but not fatal, and it is fully explained by the treemap. The correctness symptom is categorical: two React instances mean two module-level singletons, and any cross-boundary interaction — a useContext reading a provider from the other copy, a useState whose dispatcher belongs to the wrong instance — throws or silently returns stale values. A build can exhibit the size symptom without the correctness symptom (two identical copies of a stateless utility like lodash-es waste bytes but never misbehave), so identify which class of duplication you have before deciding how hard to push on the fix. React duplication is always both; a date library is usually only the first.
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.
Read the npm ls output as a resolution map, not a version list. Even when both paths report the same version string — say both are react@18.3.1 — two distinct install locations still produce two module instances at build time, because the resolver keys on the absolute file path it lands on, not on the name@version printed for humans. That is why a dedupe-by-version install step is not sufficient on its own: the package manager can legitimately keep two physically separate directories that happen to hold the same version, and Rollup will faithfully bundle both. The version mismatch case (two genuinely different majors) is a harder variant where dedupe cannot silently merge them; you must first reconcile the versions in the lockfile, because collapsing two incompatible React majors onto one copy would break whichever dependency required the other.
resolve.dedupe works by rewriting resolution so that a bare specifier like react, wherever it is imported from, resolves to a single copy nearest the project root rather than to the copy nested beside the importer. It is a build-time resolution override, not an install-time change — it does not touch node_modules, so npm ls will still show two paths after you add it; the merge happens only in the bundle. Confirm dedupe worked by inspecting the emitted chunks, never by re-running npm ls.
Diagnosis Workflow
-
Build with the visualizer. Add
rollup-plugin-visualizerand rebuild. Opendist/stats.htmland search forreact-dom. If the treemap shows it inside two chunks, duplication is confirmed. The treemap is the fastest read because it groups modules by their emitted chunk, so a duplicated package renders as two separately labelledreact-domrectangles under two different parent chunks rather than one. Prefer thetreemaptemplate withgzipSize: true— a raw-byte view can hide the true transfer cost of a second copy, and the gzip figure is what your CDN actually serves. If you see the package once but a route chunk is still surprisingly large, the duplication may be a transitive dependency rather than the headline package, so widen the search toschedulerandreact/jsx-runtimebefore concluding the build is clean.// 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' }), ], }); -
Grep the emitted chunks. A reliable, CI-friendly check counts how many chunks embed a React-internal marker. More than one means duplication. This works where the visualizer cannot be automated: the grep returns a number you can assert on in a script, and it inspects the actual shipped bytes rather than a metadata report. Pick a marker string that is stable across minor React releases and appears exactly once per copy —
react-dom.productionis a good anchor because it is part of the production build’s error-suppression banner and does not appear in your own source.grep -lprints one filename per matching chunk, so the count of matched files equals the count of copies; a value of2or more is the same signal the treemap gave you, now in a form a build gate can fail on.# Vite 5.x — expect exactly 1 grep -lR "react-dom.production" dist/assets/*.js | wc -l -
Check for multiple resolved versions. Run
npm ls react. Two paths means the duplication is a resolution problem thatmanualChunksalone cannot fix — you also needresolve.dedupe(or a workspace override) so both importers resolve to the same module instance. This step is what separates a resolution cause from a placement cause, and it changes the fix entirely. Ifnpm lsshows a single path,manualChunkson its own will collapse the copies, because there is only one instance and you are merely telling Rollup where to put it. If it shows two paths, no amount of chunk-naming will help, because you are asking Rollup to place two distinct modules into one chunk — which it will do, leaving both copies inside that single chunk and defeating the purpose. Always run this before writing config so you know which lever you actually need. -
Inspect inconsistent chunk naming. If your
manualChunksfunction 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 classic mistake is deriving the chunk name from something environment-specific — an absolute path prefix, a workspace folder that differs between CI and a laptop, or a hash of the module ID — so the same package is namedvendor-reacton one machine andvendor-react-2on another, and the two names produce two chunks. AmanualChunksfunction must be a pure function of package identity: givenreact, it must return the same string every time, on every machine, regardless of where the repository is checked out. Normalize on thenode_modules-relative package name, never on the absolute path.
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. The cross-platform character class [\\/] is deliberate: Rollup module IDs use forward slashes on POSIX and can contain backslashes on Windows, so hardcoding / would make the rule silently fail to match on a Windows CI runner and quietly reintroduce the duplication you just fixed. Include scheduler in the same rule because React and react-dom both import it as a peer of their internal event loop; leaving it out lets scheduler land in a route chunk while the rest of the family is hoisted, which is a subtle second-order duplication if two routes each reach it.
The return 'vendor' fallback is a design choice, not boilerplate. Returning a single catch-all vendor name collapses every other dependency into one large shared chunk, which maximizes cross-route cache reuse but produces a chunk that any dependency bump invalidates. Returning undefined instead lets Rollup apply its default heuristic, which co-locates each dependency with the route that pulls it — smaller initial payloads per route but more duplication risk for widely-shared libraries. Neither is universally correct; pick the fallback that matches how often your dependency set churns versus how many routes share it.
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(),
],
};
How it works under the hood
Rollup builds the chunk layout in three ordered phases, and manualChunks intervenes in only the first of them. First, it walks the module graph from every entry and records which entries can reach each module — a module’s reachability set. Second, it assigns modules to chunks: manualChunks runs here, and its return value is an override that says “regardless of reachability, put this module in the chunk named X.” Third, it computes each chunk’s content hash and rewrites the import statements in dependent chunks to reference the hashed filename. Understanding this order explains most of the fix’s edge cases: manualChunks can move a module that is reachable, but it cannot conjure a chunk for a module nothing imports, and it runs before hashing so a stable chunk name is a prerequisite for a stable hash.
The hash in [name]-[hash].js is a digest of the chunk’s final rendered content plus the hashes of the chunks it imports. That transitive property is the whole point of pulling React into its own chunk: because vendor-react changes only when React itself changes, its hash is stable across your application deploys, so a returning visitor re-downloads only the route chunks that actually changed and serves vendor-react from cache. If React instead lived inside each route chunk, every application edit would change that route’s content, rotate its hash, and force a re-download of the embedded React bytes — the caching regression is the long-run cost of duplication, separate from the one-time size hit.
Because placement runs before hashing, an unstable manualChunks return value poisons caching even when it does not cause visible duplication. If the function returns vendor-react on Monday and vendorReact on Tuesday for the same input, the chunk filename changes, its hash changes, and every dependent chunk that imports it also rotates its hash — a cache-busting cascade from a naming typo. This is why determinism is not a nicety but a correctness requirement for the function form.
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.
CI integration
Duplication regresses silently — a dependency bump or a new UI library can reintroduce a nested React copy months after you fixed it, and nothing in the build output complains. Convert the diagnosis grep into a gate so the regression fails the build instead of shipping. The check is cheap enough to run on every pull request and needs no extra tooling beyond the shell.
# ci/check-single-react.sh — Vite 5.x / Rollup 4.x
# Fails the build if react-dom is emitted into more than one chunk.
set -euo pipefail
count=$(grep -lR "react-dom.production" dist/assets/*.js | wc -l | tr -d ' ')
if [ "$count" -ne 1 ]; then
echo "vendor duplication: react-dom found in $count chunks (expected 1)" >&2
exit 1
fi
echo "single react copy confirmed"
Run it after vite build in the same job so it sees the real artifacts. The equality assertion (-ne 1) is stricter than a “greater than one” check on purpose: a count of 0 means the marker string changed in a React upgrade and the gate has gone blind, which you want surfaced as a failure rather than a silent pass. When that happens, re-pick the marker against the new production build rather than loosening the comparison.
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.
Related
- Code Splitting Strategies for Large Applications — the chunk-boundary design this dedupe fix fits into.
- Dynamic import() code splitting patterns for React — lazy routes where duplicated React first surfaces as Invalid hook call.
- Tree-Shaking Mechanics and Dead Code Elimination — why a named manualChunk can vanish when unused.
- Core Concepts of Modern Bundling — the reachability rules behind what lands in a chunk.