Finding Duplicate Dependencies in a Bundle
$ npx source-map-explorer dist/assets/vendor-8k2Lm1.js
...
node_modules/lodash/lodash.js 71,234 bytes
node_modules/some-ui-widget/node_modules/lodash/lodash.js 68,912 bytes
Two entries, same package, two different paths under node_modules. This is the duplicate-dependency pattern: a transitive dependency (some-ui-widget above) pinned an incompatible version range of lodash, so the package manager nested a second physical copy inside it rather than reusing the top-level one. Both copies get pulled into the bundle because they are, as far as the bundler’s static analysis is concerned, two entirely separate modules — even though they are functionally identical code. On a bundle with several such duplicates (React, a date library, and a UI widget’s private lodash copy is a common trio), the aggregate cost is routinely 100–250 KB of pure redundancy.
Root Cause: Why the Same Package Resolves Twice
Root cause analysis starts one level up, in the attributing bundle bloat with source maps cluster — this is one of the two specific patterns that byte attribution is best at surfacing, the other being retained dead code. If you have not yet run that attribution to confirm a duplicate exists rather than assumed it, the sibling guide analyzing chunks with source-map-explorer covers the exact CLI invocation for isolating one chunk and reading its treemap output — the dashboard-9pQr3f.js example there shows the identical two-path pattern this page starts from.
The mechanism behind the duplicate itself is a node_modules resolution artifact, not a bundler bug: npm, pnpm, and Yarn all attempt to flatten the dependency tree so that compatible version ranges share one physical copy at the top level of node_modules. When two dependencies require version ranges of the same package that cannot both be satisfied by one installed version — say your app depends on lodash@^4.17.0 directly, and a UI library depends on lodash@^3.10.0 — the package manager nests a second, private copy inside that UI library’s own node_modules directory rather than breaking either dependency.
Bundlers resolve imports strictly by file path. import _ from 'lodash' inside your app code and the same statement inside the nested UI library’s source resolve to two different absolute paths on disk, so Webpack 5 and Rollup (Vite’s bundler) both treat them as two unrelated modules — there is no step in ordinary module resolution that would notice they are semantically the same package and merge them. Whether or not tree-shaking is otherwise configured correctly (see advanced tree-shaking and dependency optimization for that separate concern), a duplicate resolved at two paths ships twice regardless.
Exact Detection and Fix Commands
Step 1 — confirm the duplicate with npm ls
Byte attribution tells you a duplicate exists by path; npm ls tells you the full dependency chain that caused it.
# Show every resolved version of lodash and what pulled each one in
npm ls lodash[email protected]
├── [email protected]
└── [email protected]
└── [email protected]
For pnpm or Yarn, the equivalent commands are pnpm why lodash and yarn why lodash — both print the same dependency-chain information in a package-manager-specific format.
Step 2 — try automatic deduplication first
# Attempt to collapse compatible ranges into fewer physical copies
npm dedupenpm dedupe re-resolves the tree and hoists any version that can satisfy multiple dependents into a single shared copy. It only helps when the version ranges are actually compatible — in the example above, some-ui-widget’s ^3.10.0 requirement cannot be satisfied by the installed 4.17.21, so npm dedupe will leave the nested copy in place. Re-run npm ls lodash afterward to confirm whether the duplicate actually collapsed.
Step 3 — force a single version with overrides
When the version ranges are genuinely incompatible on paper but the package still works fine against the newer major version in practice (common for utility libraries with stable APIs across majors), force resolution with overrides in package.json:
// package.json — npm/pnpm overrides force a single lodash version tree-wide
{
"overrides": {
"lodash": "^4.17.21"
}
}// package.json — Yarn's equivalent field is "resolutions", not "overrides"
{
"resolutions": {
"lodash": "^4.17.21"
}
}After adding either field, delete the lockfile and node_modules, then reinstall so the resolver rebuilds the tree under the forced constraint:
rm -rf node_modules package-lock.json
npm install
npm ls lodash # should now show a single resolved versionCaution: overrides bypass semver safety entirely. If some-ui-widget genuinely depends on lodash 3.x-only behaviour, forcing it to 4.x can introduce a silent runtime bug rather than a build-time error — test the affected component after applying the override, not just the bundle size.
Step 4 — add a bundler-level guard as a safety net
Even after overrides fixes the install-time tree, a bundler-level dedupe rule protects against the next dependency bump reintroducing a nested copy before anyone notices.
Vite 5+:
// vite.config.ts — Vite 5+: force these packages to resolve to one copy
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
dedupe: ['lodash', 'react', 'react-dom'],
},
});Webpack 5:
// webpack.config.js — Webpack 5: alias forces every import to one physical path
const path = require('path');
module.exports = {
resolve: {
alias: {
lodash: path.resolve(__dirname, 'node_modules/lodash'),
},
},
};The Webpack alias approach is stricter than Vite’s dedupe — it rewrites every lodash import to one absolute path regardless of which node_modules directory would otherwise have been resolved, so it works even without an overrides entry, at the cost of masking rather than fixing the underlying version conflict.
For dependencies you have already isolated into their own named chunk — a common pattern covered in vendor chunk isolation and third-party management — a duplicate is doubly costly, because it can end up split across two different vendor chunks rather than deduplicated within one. A manualChunks or splitChunks rule that groups packages by name without an accompanying dedupe/alias rule will happily place both resolved copies of lodash into the same named chunk, which hides the duplication from a chunk-count view while leaving the byte cost fully intact. Apply the dedupe or alias rule first, then let the vendor-chunking rule group what remains — reversing that order can mask the problem rather than fix it.
Step 5 — verify with attribution again
# Confirm the duplicate is gone after applying the fix
npx source-map-explorer dist/assets/vendor-*.js | grep -i lodashA successful fix shows exactly one lodash entry in the attribution output, at roughly the byte count of a single copy rather than two.
Step-by-Step Verification
- Re-run
npm ls <package>after any fix. It should print exactly one resolved version with no nested duplicate entries. - Re-run byte attribution and diff against the pre-fix report. The package should appear once, and total chunk bytes should drop by roughly the size of the removed copy.
- Test the dependency that required the older range. If you used
overridesto force a newer major version than it originally required, exercise the specific feature that depends on it — visually and functionally, not just a build-success check. - Check for React specifically with a runtime assertion, since a duplicate React copy causes functional bugs before it becomes a size problem.
console.log(require('react-dom').version)from two different points in your component tree should print the same version and, more importantly,React === require('react')should hold true whereverReactis imported. - Add the bundler-level dedupe/alias rule to version control so it survives future dependency bumps, and consider a CI check that fails if
npm ls <critical-package>reports more than one resolved version for packages likereactorreact-dom.
Edge Cases and Gotchas
Gotcha 1 — dedupe hides a duplicate from npm ls but not from the bundle
npm dedupe can sometimes report success while a bundler-specific pre-bundling step (Vite’s dependency optimizer, for instance) still caches an older resolved copy from before the dedupe ran. Clear the bundler’s dependency cache explicitly after any deduplication step:
# Vite 5+: clear the dependency pre-bundle cache after deduplicating
rm -rf node_modules/.vite
npx vite buildGotcha 2 — monorepo workspaces duplicate a package across packages, not just across dependency ranges
In a workspace-based monorepo, two internal packages can each declare their own version of a shared dependency in their individual package.json, and workspace hoisting rules vary by package manager on whether that collapses to one copy. This is a distinct failure mode from the transitive-dependency case above — the fix is a workspace-level overrides/resolutions entry at the monorepo root rather than inside any individual package, combined with the same resolve.dedupe or resolve.alias bundler guard scoped to the app’s own build config.
Gotcha 3 — a “duplicate” that is actually two different packages with the same export name
Occasionally byte attribution shows two similarly-named entries that are not actually the same package — for example date-fns and a fork published under a scoped name with an identical API. npm ls will not show these as related because they are genuinely different packages; only a manual read of the two file paths in the attribution report reveals whether this is a true duplicate or a case where two different libraries were adopted for the same purpose over time and one should simply be removed from the codebase entirely.
Gotcha 4 — the duplicate only appears in the production build, not in development
Vite’s dev server pre-bundles dependencies once per session and caches the result in node_modules/.vite; that cache can end up pointing at a single resolved copy even when the production Rollup build, which re-resolves the full graph from scratch, picks up both. If npm ls shows a duplicate but the dev server “feels” fine, that is expected — always validate duplicate-dependency fixes against a real vite build / webpack --mode production output, never against dev server behaviour alone.
Applying all three layers together — install-time overrides, a bundler-level dedupe/alias guard, and a re-run of byte attribution to confirm — is what makes the fix durable across future dependency bumps rather than a one-time cleanup that quietly regresses the next time someone runs npm install on an unrelated feature branch.
FAQ
Why does npm install two versions of the same package in the first place?
When two of your dependencies each require incompatible semver ranges of the same package, npm’s resolver cannot satisfy both with one copy, so it nests a second copy inside whichever dependency needs the different range. This is correct behaviour for avoiding version conflicts at install time, but it means both copies ship to the browser unless something deduplicates or aliases them at build time.
Is resolve.dedupe in Vite the same thing as npm dedupe?
No. npm dedupe rewrites the physical node_modules layout on disk so npm’s own resolver returns one copy. Vite’s resolve.dedupe operates only inside the bundler’s own module resolution — it forces every import of a listed package to resolve to one specific copy at build time, regardless of what node_modules actually contains on disk. Bundler-level dedupe is a safety net for cases npm dedupe cannot fully collapse.
Can duplicate React instances cause bugs beyond bundle size?
Yes, and this is often more urgent than the byte cost. Two React copies mean two separate internal hook-state registries; a component tree spanning both copies triggers “Invalid hook call” errors or silently broken Context because a Provider from one copy is invisible to a Consumer resolved from the other. This typically points to a monorepo or a linked local package resolving its own nested React rather than the host application’s copy.
Related
- Attributing Bundle Bloat with Source Maps — parent cluster covering the byte-attribution workflow this duplicate was found in
- Analyzing Chunks with source-map-explorer — sibling guide for the exact CLI flags used to spot this duplicate
- Vendor Chunk Isolation & Third-Party Management — keeping deduplicated packages together in a stable, cacheable chunk
- Advanced Tree-Shaking & Dependency Optimization — related dependency-tree hygiene once duplicates are resolved