Finding Duplicate Dependencies with npm overrides

A bundle that ships two copies of the same library — often because a transitive dependency pins an incompatible range — wastes bytes and, for stateful packages, breaks at runtime. This guide detects the duplicate, forces a single version with an overrides/resolutions entry, and verifies the bundle shrank. It is the deduplication fix referenced in bundle analysis and size budgeting; the single-instance requirement it enforces also underlies ESM/CJS interop hazards.

The problem exists because npm’s dependency resolution is per-consumer, not per-tree. Every package declares a semver range for its own dependencies, and the installer is free to satisfy each range independently. When your application asks for date-fns@^3 and a transitive library asks for date-fns@^2, there is no single version that satisfies both ranges, so the installer places one copy at the top of node_modules and nests the other copy inside the library that needs it. Both copies are real files on disk, both get imported by different modules, and the bundler — which resolves imports by walking node_modules from each importer’s location — has no way to know they are “the same library.” It treats them as two unrelated modules and emits both.

For a stateless utility like date-fns the only cost is bytes: you ship the tree-shaken slice of version 2 and the slice of version 3, paying twice for functionality you use once. For a stateful package the cost is a correctness bug. Two copies of a library that keeps module-level state — a React renderer, a state store, a context provider, an event emitter registry — each maintain their own copy of that state, so a value set through one instance is invisible to code reading through the other. The classic symptom is React throwing “Invalid hook call” or a context reading undefined even though a provider is mounted, because the provider and the consumer closed over different react module instances. Deduplication is the fix for both the byte cost and the correctness bug, and it sits at the very end of the build pipeline’s resolution phase: after the installer has laid out node_modules but before the bundler walks it.

The lever this guide reaches for — overrides (npm/pnpm) or resolutions (Yarn) — is deliberately blunt. It rewrites the requested range for a package everywhere in the tree, so every consumer resolves to the one version you name, and the installer collapses the two nested copies into a single hoisted one. That bluntness is also the risk: you are overriding the version a library author explicitly asked for, and if the forced version drops or changes an API that library depends on, you have traded a duplicate for a runtime crash. The workflow below therefore pairs every override with a verification step, and the gotchas section is where most of the real engineering judgement lives.

Two versions collapse to one via an override App and a transitive dependency each resolve a different version of the same package so both ship; an overrides entry forces every consumer onto one version, the installer deduplicates, and the bundle carries a single copy. Force one version, ship one copy app → date-fns@3 lib → date-fns@2 overridesforce date-fns@3 one copy in bundlededuplicated
Figure: the override is a single pin that collapses two resolved versions into one shared copy.

Prerequisites & reproducible setup

# npm 10 / Node 20+, Vite 5.4.x
npm create vite@latest dedupe-demo -- --template react-ts
cd dedupe-demo && npm install
# Install two libs that each depend on a different date-fns range.
npm install date-fns@3 some-old-lib   # some-old-lib pins date-fns@^2
npm ls date-fns                       # shows both versions in the tree

npm ls date-fns printing two versions at two paths is the signature of a duplicate that will ship twice unless forced to one version. The two-path detail matters: a single version listed at one path is fine, and even a single version listed at several paths is fine because the installer has already hoisted it to one physical directory. What you are hunting for is two distinct version numbers, which guarantees two physical directories and therefore two modules the bundler will emit separately.

The reproducible setup above is chosen so the conflict is unavoidable. date-fns@3 and a library pinned to date-fns@^2 cannot share a version, because no release satisfies both ^2 and ^3 at once. That is the precondition for a hoisting split — if the ranges had overlapped (say ^2.1 and ^2), the installer would have found a single satisfying version and there would be nothing to deduplicate. If you cannot reproduce a duplicate with your own dependencies, it is usually because your ranges do overlap and npm already picked one version for you; in that case there is no fix to make and no bytes to recover. Run npm dedupe first — it re-runs the hoisting algorithm against the existing lockfile and collapses any duplicates that the ranges actually permit, without any override at all. Only reach for an override when npm dedupe leaves two versions behind, which is the installer telling you the ranges are genuinely incompatible.

How overrides resolve under the hood

An override is applied before dependency resolution begins, not after. When npm reads package.json, it builds an “override set” — a map from package name (optionally scoped by which parent requested it) to a replacement specifier. During the resolution walk, every time the installer is about to record the range a package requested for one of its dependencies, it consults the override set first; if there is a match, the requested range is discarded and the override’s specifier is substituted. The library that asked for ^2 never gets ^2 recorded against it — from the resolver’s point of view it asked for 3.6.0 all along. Because every consumer’s request is rewritten to the same specifier, the resolver naturally satisfies them all with one version, and hoisting places that single copy at the top of the tree.

This is why an override changes install behaviour but not source code: the library’s own package.json on disk still says ^2, and if you inspect it you will see the “wrong” range. The lie lives only in the resolver’s in-memory override set and in the resulting lockfile, which records the concrete version each edge resolved to. That also explains why the lockfile must be regenerated for an override to take effect — the previously resolved edges are baked into package-lock.json, and npm will honour the existing lockfile over a freshly added override unless you force a re-resolve. pnpm applies the same idea through pnpm.overrides but resolves into a content-addressed store and a symlinked tree, so its hoisting is stricter by default; a duplicate that npm silently flattens can remain two entries under pnpm until you override it explicitly.

Two versions at two paths in the dependency tree The reproducible setup installs a direct dependency on date-fns 3 and a library that pins date-fns 2, so npm ls prints both versions at different paths — the tree-level signature that the package will ship twice. app date-fns@3 (direct) date-fns@2 (via some-old-lib)
Figure: two branches of the tree resolving different versions is exactly the state the override collapses.

Diagnosis workflow

  1. List every resolution of the package. npm ls <pkg> (or pnpm why <pkg>) prints the full tree, annotating each edge with the requested range and the version it resolved to. Read it from the bottom: the leaves tell you which parent pulled in the copy you did not expect. If two distinct versions appear, you have a duplicate; if the same version appears at several paths, the installer already deduplicated it and there is nothing to force. Add --all on npm to expand deduplicated edges that the default output collapses, otherwise a single-line summary can hide the second consumer entirely. The failure mode here is trusting a truncated tree — npm ls silently caps depth in large graphs, so confirm with npm ls <pkg> --all before concluding there is only one version.
  2. Check the ranges are compatible. Look at the two requested ranges, not just the resolved versions. If the transitive consumer’s range (^2) admits the version you want (3), then the ranges overlap and a plain npm dedupe will collapse them — no override needed. Here ^2 explicitly excludes 3, so no shared version exists and dedupe is powerless; the override is the only lever that forces the merge. Getting this backwards is the most common wasted hour: people add an override where a dedupe would have sufficed, permanently pinning a version they did not need to pin. Confirm incompatibility by reading the caret ranges directly, or run npm dedupe and see whether the second version survives.
  3. Confirm it actually ships twice. A version in the tree only costs bytes if the bundle actually imports it. Build with source maps and open the analyzer treemap; a real duplicate shows the package name in two separate boxes, each with its own size. If only one box appears, the second copy was tree-shaken away or never reached — the tree is misleading and there is no shipping duplicate to fix. This step is what separates a byte problem from a phantom: npm ls reports the installed graph, but only the treemap reports the shipped graph, and an override is only worth adding when both agree. Note the combined size of the two boxes; that number, minus the size of one copy, is the byte cost you will recover.
Confirm the duplicate in the tree and in the bundle npm ls reveals two versions resolved in the dependency tree and the analyzer treemap confirms the package name appears at two sizes in the bundle; both signals together prove a real duplicate worth forcing to one version. npm ls: two versionsthe tree treemap: two boxesthe bundle both agree confirm in the tree and the bundle before forcing
Figure: a version in the tree only matters if it also ships — confirm both before overriding.

Annotated solution config

All three package managers express the same idea — rewrite the requested range everywhere — but under different field names and with different scoping rules. Pin to a concrete version (3.6.0), not a range (^3), when the whole point is to eliminate ambiguity; a range override leaves the resolver free to pick different patch versions on different installs, which is exactly the non-determinism you are trying to remove. The one exception is when you deliberately want the override to track patches: $date-fns in npm’s syntax reuses whatever version your own direct dependency resolved to, so "date-fns": "$date-fns" keeps the transitive consumer locked to your app’s copy without hard-coding a number.

// package.json (npm 10) — force every consumer onto one version.
{
  "overrides": {
    // Every resolution of date-fns, including transitive, uses 3.6.0.
    "date-fns": "3.6.0"
  }
}
// package.json (pnpm) — the equivalent resolutions block.
{
  "pnpm": {
    "overrides": {
      "date-fns": "3.6.0"
    }
  }
}
// package.json (Yarn) — resolutions field.
{
  "resolutions": {
    "date-fns": "3.6.0"
  }
}

A blanket override is the right default, but it is heavy-handed: it forces date-fns@3.6.0 on every consumer in the tree, including ones you never inspected. When you want to force the version only for the specific library that caused the split — leaving the rest of the tree free to resolve normally — use the scoped forms below. This is the safer override when you are not certain the forced major is safe everywhere, because it narrows the blast radius to exactly the edge you diagnosed.

// package.json — scope the override to one parent only.
{
  // npm 10: nest the override under the parent package name.
  "overrides": {
    "some-old-lib": {
      "date-fns": "3.6.0"   // only some-old-lib's date-fns is forced
    }
  },
  // Yarn: use the parent/child glob syntax in resolutions.
  "resolutions": {
    "some-old-lib/date-fns": "3.6.0"
  }
}
The same forced-version idea across three package managers npm and pnpm both express the pin as an overrides block while Yarn uses a resolutions field, but all three force every direct and transitive consumer of the package onto the single named version. one idea, three field names npm: overridestop-level pnpm: pnpm.overridesnested Yarn: resolutionstop-level
Figure: the field name differs by package manager, but the effect — one forced version everywhere — is identical.

Verification

# npm 10 — apply the override, reinstall, and confirm one version.
rm -rf node_modules package-lock.json && npm install
npm ls date-fns          # expect a single version, deduped
npx vite build --sourcemap && npx source-map-explorer 'dist/assets/*.js'

After the override and a clean reinstall, npm ls date-fns shows one version, and the analyzer treemap has a single date-fns box. The bundle’s raw and gzip size both drop by roughly the size of the removed copy.

The clean reinstall is not optional. Deleting node_modules alone is not enough, because npm will rebuild the identical tree from the still-present lockfile — the lockfile is the record of the old resolution and it outranks a freshly added override until you regenerate it. Delete both node_modules and package-lock.json so the resolver re-runs from package.json with the override set active. On pnpm the equivalent is rm -rf node_modules pnpm-lock.yaml && pnpm install; on Yarn, remove yarn.lock. If you skip this and the duplicate persists, the override is almost never wrong — the stale lockfile is.

Measure the win in gzip, not raw bytes, because gzip is what the browser downloads and it does not scale linearly with source size. A duplicate that adds 40 KB of raw JavaScript might only add 12 KB gzipped if the two copies compress against similar dictionaries, or it might add nearly the full 40 KB if the code is dissimilar. Record the before and after gzip figures from the same analyzer run so the comparison is apples to apples; a raw-byte drop that does not move the gzip number is not worth an override that pins a version you will have to maintain. Confirm the runtime is still correct as well as smaller — for a stateful package, load the page and exercise the feature that reads shared state, because a dedup that shrinks the bundle but breaks a hook is a regression, not a fix.

CI integration

The value of a dedup is not the one-time byte drop; it is preventing the duplicate from silently returning when someone bumps a dependency. Enforce it in CI with a size budget and a resolution check that fail the build rather than warn. npm ls <pkg> exits non-zero when the tree is in an unexpected state, so a one-line guard catches a reintroduced second version before it merges.

# ci.sh — fail the build if a forbidden duplicate reappears.
# npm 10 / Node 20+
set -euo pipefail
count=$(npm ls date-fns --all --parseable 2>/dev/null \
  | grep -c 'node_modules/date-fns$' || true)
if [ "$count" -gt 1 ]; then
  echo "date-fns resolved to $count copies — an override regressed"
  exit 1
fi

Pair that with a byte budget so a duplicate of any package — not just the one you already know about — trips the build. Rollup and Vite emit per-chunk sizes; a tool like size-limit reads the built output and compares gzip size against a committed threshold, so a newly introduced duplicate that pushes a chunk over budget fails the pull request with the exact package named. Commit the override and the budget together: the override is the fix, the budget is the thing that keeps the fix honest after you move on.

One version in the tree, one box in the bundle A successful dedup shows a single version from npm ls and a single package box in the analyzer treemap, and the bundle's raw and gzip size drop by approximately the size of the copy that was removed. npm ls: one versiondeduped bundle: one box, smallerbytes recovered both views must show a single copy
Figure: success is a single copy in both the dependency tree and the bundle treemap.

Gotchas & edge cases

Four deduplication edge cases Forcing a major version can break a consumer that relied on the old API; a peer-dependency mismatch may still warn after the override; the lockfile must be regenerated for the override to take effect; and some duplicates are two genuinely incompatible majors that cannot be merged safely. forced major breaks consumer→ test the transitive user peer mismatch warns→ align the peer range stale lockfile→ regenerate it genuinely incompatible majors→ cannot safely merge
Figure: an override is a promise the forced version works for every consumer — test the transitive one before trusting it.
  • A forced major can break a consumer. The transitive dependency asked for ^2 because its author validated it against the version 2 API. Forcing 3 hands it a library whose functions may have been renamed, removed, or changed signature — date-fns@3 moved to ESM-first exports and dropped several v2 entry points, so a v2-era import can resolve to undefined at runtime. The symptom is not an install error but a crash the first time the forced consumer calls the changed function, which may be a code path your tests never hit. The root cause is that an override suppresses semver’s protection without adding any of its own. The fix is to actually run the transitive consumer’s behaviour — its own test suite if it ships one, otherwise the feature in your app that exercises it — and to prefer the scoped override so only that one library is affected. Confirm by grepping the forced library’s source for the APIs it imports and checking each still exists in the forced version.
  • Peer-dependency warnings persist. An override rewrites a regular dependencies edge, but peerDependencies are a separate contract the installer only warns about, never rewrites. So after forcing date-fns@3 you may still see “peer date-fns@^2 required” from a plugin that declares it as a peer. The warning is not cosmetic — it is the plugin telling you it was written against version 2 and may misbehave against 3. The fix is to bring the peer into range, usually by upgrading the plugin to a version whose peer range admits 3, or by adding a matching override for the plugin itself. Accepting the warning is defensible only when you have verified the plugin does not actually touch the changed API. Confirm alignment by re-reading the install log until no peer warning names the deduplicated package.
  • Regenerate the lockfile. The override only takes effect after rm -rf node_modules package-lock.json && npm install (or the package-manager equivalent). The reason is mechanical: the lockfile records the concrete version every edge resolved to on the last install, and npm treats it as authoritative, replaying those exact versions and ignoring an override you added afterwards. Deleting only node_modules reinstalls the same broken tree from the surviving lockfile. The symptom of forgetting this is an override that “does nothing” — npm ls still shows two versions. Confirm the regenerate worked by diffing the new lockfile: the entry for the forced package should now show a single version, and the transitive consumer’s edge should point at it.
  • Some duplicates are real. Not every duplicate is an accident to be merged. Two genuinely incompatible majors of a stateful library — a codebase mid-migration that legitimately runs react@17 in one island and react@18 in another, or two independent widgets each requiring their own major of a charting library — cannot be collapsed onto one version without breaking one of them. Forcing a single version here does not save bytes; it converts a working duplicate into a broken singleton. The correct move is to keep both versions and isolate them by scope so they never share state — separate entry chunks, or a scoped override that pins each subtree independently. Confirm the split is intentional by checking that no shared module (a context, a store, a singleton) is expected to cross the boundary between the two versions.

When not to reach for an override

An override is a maintenance liability: it is a hidden pin that future dependency upgrades will silently fight against, and six months later nobody remembers why date-fns is frozen at 3.6.0. Do not use one when a cheaper fix exists. If the ranges overlap, npm dedupe collapses the duplicate with no permanent pin. If the duplicate came from an outdated direct dependency, bumping that dependency so its range matches the rest of the tree removes the split at the source and ages better than an override. If the duplicated package is stateless, tiny, and tree-shakes to a few hundred bytes, the duplicate may simply not be worth the pin — an override you have to reason about on every future upgrade can cost more engineering time than the bytes it saves. Reserve overrides for the case they are actually good at: an incompatible range you do not control, on a package where a second copy is either expensive or incorrect, where forcing one version is verifiably safe. Every override you add should carry a comment naming the duplicate it resolved, so the next person can tell whether it is still needed.