How to Find Duplicate Dependencies in a Monorepo Bundle

This walkthrough extends Webpack Bundle Analysis Techniques inside JavaScript Bundle Optimization & Code Splitting. The bundle report shows date-fns three times, @internal/ui twice, and a suspiciously large vendor chunk. In a workspace repository this is normal and it is not a bundler bug: two packages asked for incompatible ranges of the same dependency, the package manager installed both, and the bundler faithfully included each one.

Duplicates cost bytes, parse time, and correctness — two copies of a state library mean two independent stores.

Rapid Diagnosis: Confirm and Quantify

  • Read the analyzer treemap for repeated names. The same package name appearing under two different paths is the signal; the nesting tells you who pulled each copy.
  • Ask the package manager first. npm ls <pkg> or pnpm why <pkg> shows the resolution tree and the requested ranges — the actual cause.
  • Separate real duplicates from re-exports. A package appearing twice with different sizes is usually two versions; identical sizes under different paths can be a symlink or an alias artefact.
  • Check for singleton violations. React, state stores and context providers must be single instances. A duplicate here causes bugs, not just bytes: hooks fail, contexts read as undefined.
Why the same package is installed twice Two workspace packages request incompatible ranges of a shared dependency, so the package manager nests a second copy; aligning the ranges lets both resolve to one hoisted install. Two ranges, two installs Before root workspace app wants ^2.3 ui wants ^1.9 lib 2.3.1 (44KB) lib 1.9.4 (41KB) 85KB shipped, one library After aligning ranges root workspace app wants ^2.3 ui wants ^2.3 lib 2.3.1 (44KB) 44KB shipped, one instance

Root Cause Analysis

1. Incompatible semver ranges across workspace packages. One package pins ^1.9 while another requires ^2.3. The package manager cannot satisfy both with one install, so it nests a second copy — correct behaviour, expensive outcome.

2. A dependency that should have been a peer. A shared library declared as a regular dependency inside an internal package gets its own copy per consumer. Declaring it as a peer dependency instead makes the consumer supply the single instance.

3. Mixed module formats. The same package resolved once as ESM and once as CommonJS produces two distinct modules in the graph even at the same version — the mechanism behind dual package hazard.

4. Aliases and symlinks that bypass resolution. A build alias pointing at a source path while another import resolves through node_modules yields two module identities for what is, on disk, the same code.

Step-by-Step Resolution

1. Get the authoritative list of duplicates from the package manager.

bash
# Every package installed at more than one version, with who asked for what.
npm ls --all --json 2>/dev/null \
  | jq -r '[paths(objects) as $p | {name: ($p[-1]), v: (getpath($p).version)}]
           | map(select(.v)) | group_by(.name) | map(select((map(.v)|unique|length) > 1))
           | .[][0].name' | sort -u
# trade-off: this lists what is INSTALLED, which is a superset of what is
# BUNDLED — dev-only duplicates are noise here. Cross-check against the bundle
# report before acting on any entry.

Expected outcome: a short list of genuinely duplicated packages with their requesters.

2. Align the ranges at the source. The durable fix is agreeing on one range across the workspace, not overriding it downstream.

json
{
  "pnpm": { "overrides": { "date-fns": "^3.6.0" } },
  "resolutions": { "date-fns": "^3.6.0" }
}
bash
# Then align the packages that were asking for the old range.
pnpm -r update date-fns@^3.6.0
# trade-off: an override forces a version a package did not declare support for.
# It usually works and occasionally breaks at runtime in a way no type check
# catches — run the affected packages' test suites before shipping it.

Expected outcome: one install, one copy in the bundle, and the byte saving equal to the smaller copies.

3. Make shared libraries peer dependencies. Inside internal packages, anything that must be a singleton belongs in peerDependencies.

json
{
  "name": "@internal/ui",
  "peerDependencies": { "react": "^18 || ^19", "@internal/store": "workspace:*" },
  "devDependencies": { "react": "^19.0.0" }
}

Expected outcome: consumers supply the single instance; the internal package can never bundle its own.

4. Enforce single instances in the bundler as a backstop. Resolution aliases catch anything the dependency graph still splits.

javascript
// webpack: one physical path per singleton, whatever the graph says.
const path = require('node:path');
module.exports = {
  resolve: {
    alias: {
      react: path.resolve(__dirname, 'node_modules/react'),
      'react-dom': path.resolve(__dirname, 'node_modules/react-dom')
    },
    dedupe: ['@internal/store']         // Vite exposes the same idea directly
  }
};
// trade-off: an alias silently forces one version on code that expected
// another. It is a backstop for singletons, not a substitute for fixing the
// ranges — and it hides the real problem from the next person who looks.

Verification

Re-run the analyzer and confirm each previously duplicated package appears exactly once. Compare the vendor chunk's size before and after; the saving should match the sum of the removed copies, and if it does not, one copy is still being pulled through a different path.

For singletons, verify identity at runtime rather than trusting the graph:

javascript
// Two copies produce two different module identities.
import * as store from '@internal/store';
window.__storeInstances ??= new Set();
window.__storeInstances.add(store);
console.assert(window.__storeInstances.size === 1, 'duplicate store instance!');

Then keep it from returning. Duplicates reappear whenever someone adds a dependency, so add a CI check that fails the build when a package resolves to more than one version, and pair it with the bundle-size budget described in reducing vendor chunk size in a React app.

FAQ

Are duplicate packages always worth fixing?

Not always. A 3KB utility duplicated across two rarely loaded chunks is noise. Two copies of a state library, a router, or React are worth fixing immediately, because the cost is correctness rather than bytes: hooks throw, context reads as undefined, and two stores drift apart. Sort by size and by singleton-ness, and fix the second category regardless of size.

Does pnpm prevent duplication automatically?

It prevents accidental duplication from hoisting, because its strict layout means a package can only import what it declares. It cannot resolve genuinely incompatible ranges — if two workspace packages require different major versions, you get two installs under any package manager. The fix is always to align the ranges or declare a peer dependency.

Why does the analyzer show two copies when npm ls shows one?

Usually a module-format split: the same version resolved once through the ESM entry and once through the CommonJS entry, producing two module identities from one install. It can also be an alias or symlink that gives the bundler two paths to the same file. Check the analyzer's module paths — if they differ only by esm/cjs or by a src versus dist segment, that is your answer.

How do I stop duplicates from coming back?

Make the check part of the build rather than a periodic clean-up. A CI step that lists packages resolved at more than one version and fails on any entry in an allowlist of singletons catches the regression on the pull request that introduces it, when the person who added the dependency is still looking at it. Pair it with a size budget on the vendor chunk so a duplicate that slips past the name check is still caught by its bytes.

Is a duplicate always visible in the analyzer treemap?

Not reliably. The treemap groups by output chunk, so two copies split across a vendor chunk and a route chunk can look like unrelated blocks. Sorting the module list by package name is a better view, and the package manager's resolution tree is better still because it shows intent — who asked for which range — rather than just the outcome.

Do overrides break semver guarantees?

They set them aside deliberately, which is why they are a fix of last resort rather than a first move. Forcing a package to run against a version it never declared support for usually works and occasionally fails at runtime in ways no type check catches. Prefer updating the packages that request the old range; use an override when one of them is a third-party dependency you cannot change, and run its test suite before shipping.