Find Duplicate Packages Inflating Your Frontend Bundle
List version-multiplicated packages in the dependency graph, trace which import paths pull each copy, and collapse them into one.
Task
Detect duplicate package versions in the bundle, attribute each copy to its importer, and deduplicate to a single instance.
When to use this
The bundle grew 120KB after a small feature. A transitive dependency now ships its own copy of lodash and a second major of date-fns, and nobody noticed.
Prerequisites
- pnpm why or npm ls available
- A bundler analyzer (rollup-plugin-visualizer, webpack-bundle-analyzer)
- Freedom to pin or override dependency versions
Steps
1. List duplicated packages across the dependency graph.
pnpm why lodash date-fns 2>/dev/null | head -20 || npm ls lodash date-fns 2>/dev/null | head -20Expected output: Multiple version paths print, for example [email protected] and a nested [email protected] pulled by an old utility library.
2. See how much each duplicate costs in the shipped bundle.
npx vite build --mode analyze 2>/dev/null; node -e "const r=require('./bundle-report.json');r.children&&r.children.filter(c=>/lodash|date-fns/.test(c.label)).forEach(c=>console.log(c.label,(c.gzipSize/1024).toFixed(1)+'KB gzip'))" 2>/dev/null || echo "use rollup-plugin-visualizer: npx vite build && open stats.html"Expected output: The analyzer report shows each copy with its gzip size, turning an abstract duplication into shipped bytes.
3. Trace which import statement pulls the second copy.
grep -rn "from .lodash." src/ | head -5; pnpm why lodash | grep -B2 -A2 "lodash@3" | head -10Expected output: The importer chain prints: an old table component importing lodash.debounce resolves to the nested v3 copy while everything else uses v4.
4. Collapse the duplicates at the resolution layer.
// package.json (pnpm)
{
"pnpm": {
"overrides": {
"lodash": "^4.17.21",
"old-table-component>lodash": "^4.17.21"
}
}
}
// vite/webpack alias alternative
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: { lodash: 'lodash-es' },
dedupe: ['lodash-es', 'date-fns'],
},
});
Expected output: After reinstall and rebuild, the analyzer shows a single lodash entry, and the bundle shrinks by the size of the removed copy.
5. Add a CI budget check so duplicates cannot return silently.
// scripts/check-duplicates.mjs
import { execSync } from 'node:child_process';
const out = execSync('npm ls --json --all 2>/dev/null || true').toString();
const tree = JSON.parse(out || '{}');
const seen = new Map();
(function walk(node) {
for (const [name, info] of Object.entries(node.dependencies ?? {})) {
if (info.version) {
const key = `${name}@${info.version}`;
if (seen.has(name) && seen.get(name) !== info.version) {
console.error(`duplicate: ${name} ${seen.get(name)} vs ${info.version}`);
process.exitCode = 1;
}
seen.set(name, info.version);
}
walk(info);
}
})(tree);
Expected output: CI fails with a named duplicate the moment any dependency change reintroduces a second version, keeping the bundle single-copy by construction.
Troubleshooting
Problem
Override breaks the old consumer
Solution
The nested version was pinned for an API difference; upgrade the consumer instead, or scope the override narrowly and test the old component explicitly before merging.
Problem
Duplicates exist only in the built bundle, not npm ls
Solution
Mixed module formats cause double inclusion (lodash plus lodash-es); alias one to the other or add both to the bundler dedupe list.
Problem
Cannot remove the duplicate without a major upgrade
Solution
Quantify the cost in KB first; a 12KB duplicate is a ticket, not an emergency, and the CI check keeps it from compounding while the upgrade is scheduled.
Limitations
- Resolution-level dedupe changes what code executes; test the affected importers, since a nested version may exist for a reason.
- The check catches version duplication, not general bloat; keep it alongside a size budget, not instead of one.
Every duplicate package is paid for twice
Two versions of the same library in one bundle means double the bytes, double the parse time, and occasionally two separate module registries that break singletons. The duplication is invisible in code review because it is created by the resolver, not by any import you wrote.
# Which packages exist twice, and why?
pnpm why lodash
# old-table-component 1.2.0
# ├─ lodash 3.10.1 <-- nested old copy
# charts-widget 4.1.0
# └─ lodash 4.17.21
# What does that cost in the artifact?
# bundle-report: lodash@3 21.4KB gzip (dead weight)
# lodash@4 24.9KB gzip (the real one)
# Collapse it
pnpm.overrides: { "old-table-component>lodash": "^4.17.21" }
# bundle after: 46.3KB -> 24.9KB gzip for lodash total
Attribute before deduplicating
Knowing that lodash is duplicated is trivia; knowing that old-table-component pulls the v3 copy is a fix. The why command supplies the importer chain, which tells you whether an override is safe or whether the consumer needs an upgrade first. Skipping attribution is how overrides break production.
Duplication is a regression class
Every new dependency can silently bring its own preferred version of a library you already ship. The CI duplicate check evaluates the whole tree on every lockfile change, so the second copy fails the build by name instead of accumulating across a dozen PRs into the next 120KB mystery.