Resolving Duplicate Lockfile Versions in a Monorepo
Two workspaces resolve react to 18.2.0 and 18.3.1 in the same package-lock.json, and the local build behaves differently depending on which package you enter first — a duplicate-version drift that dependency tree visualization exists to surface. This guide reproduces the duplicate with a resolution query, explains why a monorepo lockfile keeps more than one copy of a package, and walks through collapsing the tree back to a single version so every workstation and CI runner installs the same graph.
Diagnostic
A duplicate lockfile version rarely errors outright. It shows up as a hook that "isn't a function" because two copies of React disagree on identity, a type that is assignable in one package and not in another, or a bundle that is mysteriously larger on one engineer's machine than in CI. The symptom is drift: the same command produces a different node_modules graph in two places because the lockfile permits more than one resolution. Before fixing it you need a query that names every version of the offending package and the path that pulled each one in.
#!/usr/bin/env bash
set -euo pipefail
# npm workspaces: list every resolved copy of the package
npm ls react --all 2>/dev/null || true
# pnpm workspaces: show why each version is present
pnpm why react -r || true
# Raw lockfile scan — count distinct resolved versions
grep -oE '"react@[^"]+"|/react@[0-9][^:]*' pnpm-lock.yaml package-lock.json 2>/dev/null \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u
Run the resolution query and the raw scan together. The package manager's own ls/why output tells you which workspace requested each copy, while the grep over the lockfile is the blunt ground truth — it counts distinct resolved versions regardless of how the manager chooses to hoist them. If the sorted, unique list has more than one line, you have a duplicate.
Expected BAD output — the tree carries two copies and each dependent pins a different range:
monorepo@ /repo
├─┬ @app/[email protected] -> ./packages/web
│ └── [email protected]
└─┬ @app/[email protected] -> ./packages/admin
└── [email protected]
react is referenced by:
packages/web dependencies react@^18.3.0
packages/admin dependencies [email protected]
18.2.0
18.3.1
The two version lines at the bottom are the whole diagnosis. Copy that list and the requesting workspaces beside it — that pairing is the exact set of package.json ranges you will reconcile in the Resolution step, and it tells you the minimum number of manifests to touch.
Root cause
A monorepo lockfile keeps two copies of a package when no single version satisfies every workspace's declared range at once, or when it could but the installer was never asked to collapse the tree. Each workspace declares its own dependency range in its own package.json. When @app/web asks for ^18.3.0 and @app/admin pins exactly 18.2.0, there is no version that satisfies both, so the resolver is forced to install one copy per constraint and record both in the lockfile. Even when the ranges do overlap — ^18.2.0 and ^18.3.0 both accept 18.3.1 — a lockfile written incrementally can freeze an older resolution for one workspace and a newer one for another, because npm only re-resolves the ranges it must and leaves already-locked entries untouched. The result is a graph that is internally consistent but carries redundant copies.
The reason this causes local build drift rather than a hard failure is hoisting. The installer lifts one version to a shared top-level location and nests the others deeper in the tree, and which copy a given import resolves to depends on the directory it resolves from. A test runner started in packages/web reaches the hoisted 18.3.1; the same import evaluated from packages/admin reaches its nested 18.2.0. For a stateless utility that difference is invisible; for a package that relies on referential identity — React's hook dispatcher, a singleton client, a Symbol registry — two copies mean two independent module states, and the failure surfaces far from the lockfile as "invalid hook call" or a context that reads as empty. The defect is structural: the range constraints admit more than one solution, and the fix is to remove that freedom, not to reinstall and hope the resolver picks better.
Resolution
- Reconcile the declared ranges so a single version satisfies every workspace, then let the installer re-resolve.
- When a transitive dependency you do not own forces the split, pin it with an
overrides(npm/yarn) orpnpm.overridesblock. - Run the manager's dedupe pass to collapse any redundant copies the range change left behind.
- Regenerate the lockfile from a clean state and confirm exactly one version remains.
The pattern behind all four steps is the same: remove the freedom that let the resolver pick two answers. Start by aligning the first-party manifests — the copy of the shared package that both workspaces control. Point every workspace at one range rather than a mix of pinned and caret specs:
#!/usr/bin/env bash
set -euo pipefail
# Set every workspace's react range to the same spec, then re-resolve
npm pkg set dependencies.react="^18.3.1" \
--workspace=@app/web --workspace=@app/admin
npm install
npm dedupe
npm ls react --all
npm pkg set edits each workspace package.json in place without hand-editing JSON, and running it across both workspaces guarantees byte-identical ranges rather than the near-misses (^18.2.0 versus ^18.3.0) that let the tree split in the first place. The follow-up npm install re-resolves against the aligned ranges, and npm dedupe walks the existing tree and hoists any copy that a broader range now permits — the step that actually collapses a 18.2.0 left frozen from an earlier install.
When the second copy is pulled by a transitive dependency you do not declare — a plugin that hard-depends on an older react — range alignment cannot reach it, and you force the resolution with an override:
// package.json (repo root) — force one version everywhere, transitive included
{
"name": "monorepo",
"private": true,
"workspaces": ["packages/*"],
"overrides": {
"react": "18.3.1"
}
}
An overrides block (resolutions in classic Yarn, pnpm.overrides in pnpm) rewrites the resolved version of every matching node in the tree, including transitive ones your manifests never mention. Use it deliberately: overriding a package below the range its dependents declared can violate a peer constraint, so pin to a version that still satisfies the dependents rather than the newest release available. For pnpm, the equivalent block plus a dedupe pass reads:
# pnpm-workspace.yaml is unchanged; the override lives in package.json
# package.json: { "pnpm": { "overrides": { "react": "18.3.1" } } }
# then, from the repo root:
# pnpm install
# pnpm dedupe
# pnpm why react -r
Choosing between range alignment and an override is a real decision, not a formality: align ranges when every requester is a workspace you control, and reach for an override only when a dependency you do not own forces the split. When the shared package has its own peer requirements — the common case with plugin ecosystems — check that the single version you pick still satisfies those peers, or you will trade a duplicate for an unmet-peer warning that reintroduces drift by a different route.
Expected output
After aligning the ranges and deduping, the resolution query reports a single copy and the raw lockfile scan collapses to one line:
$ npm ls react --all
monorepo@ /repo
├─┬ @app/[email protected] -> ./packages/web
│ └── [email protected] deduped
└─┬ @app/[email protected] -> ./packages/admin
└── [email protected] deduped
$ grep -oE '"react@[^"]+"' package-lock.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u
18.3.1
The deduped marker beside each workspace means both now resolve to the one hoisted copy rather than a private nested one, so an import evaluated from either package reaches the same module instance. The single-line scan is the durable proof — it is the exact check you will wire into CI so the duplicate cannot silently return.
Confirm the fix survives a cold install rather than trusting the mutated tree in place: delete node_modules and the lockfile, then reinstall from the manifests alone (rm -rf node_modules package-lock.json && npm install). A duplicate that a warm, incrementally-updated lockfile tolerated will reappear on a clean resolve if a stray range is still divergent, and a cold install is exactly what a new contributor and every CI runner performs on first checkout — the environment where this drift is most expensive to reproduce after the fact.
Prevention
- Gate the lockfile in CI: fail the pull request when any package resolves to more than one version, so a reintroduced duplicate never merges.
- Enforce identical ranges across workspaces with a manifest linter such as
syncpack, run as a pre-commit hook — see building an onboarding health-check script. - Keep the resolved graph visible with mapping microservice dependencies for local dev so a new split is obvious before it ships.
# .github/workflows/lockfile-single-version.yml
name: Lockfile Single Version
on:
pull_request:
jobs:
dedupe-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install from the lockfile only
run: npm ci
- name: Assert every dependency resolves to one version
run: |
DUPES=$(npx syncpack list-mismatches 2>&1 | grep -c '✕' || true)
if [ "$DUPES" -ne 0 ]; then
echo "Found $DUPES version mismatches across workspaces." >&2
exit 1
fi
echo "All workspace ranges agree; lockfile is single-version."
The economics strongly favour catching the duplicate at the boundary. A mismatched range caught by a pre-commit hook costs the author a few seconds; the same mismatch caught in CI costs a round trip and a re-review; the same mismatch that reaches main ships a lockfile whose two copies produce an "invalid hook call" whose stack trace points nowhere near the manifest that caused it. syncpack list-mismatches reads every workspace package.json and reports where the same dependency carries different ranges, which is the upstream cause of the duplicate rather than its downstream symptom. Wiring npm ci first also matters: ci installs strictly from the lockfile and fails if the lockfile and manifests disagree, so a hand-edited range that was never re-resolved is caught before the mismatch check even runs.
Platform caveats
WSL2: run
npm installand the dedupe pass from the Linux filesystem (~/project), not/mnt/c; deduping rewrites thousands ofnode_modulesentries and the cross-filesystem I/O makes a clean reinstall an order of magnitude slower. macOS (Docker Desktop): if you dedupe inside a bind-mounted container, VirtioFS can report a stale tree for a moment after the rewrite; runnpm ls react --alla second time or dedupe on a named volume so the verification reads the settled graph. Apple Silicon (ARM64): a package with a native addon can resolve to different prebuilt binaries per architecture; pin the version with an override but let the manager fetch the arch-appropriate binary, and never copy a colleague'snode_modulesacross architectures to "match" versions.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- package-lock.json package.json 'packages/*/package.json'
rm -rf node_modules && npm ci # reinstall the exact prior graph from the restored lockfile
If the override or range change introduces a regression — a peer constraint you missed, or a version bump that broke a workspace — revert the manifests and the lockfile together, then npm ci to reinstall the exact graph the restored lockfile describes. Because the fix is purely declarative, the rollback is a plain git checkout plus a clean install; no data, migrations, or container volumes are involved, and the tree returns byte-for-byte to its prior state.
Frequently Asked Questions
Why does a duplicate package version break things when both copies are the same major?
Because some packages rely on referential identity, not just API shape. Two copies of React, a database client singleton, or a Symbol registry each hold independent module state, so an object created by one copy fails an instanceof or context check performed by the other — the classic "invalid hook call" or empty-context bug. Even a minor version gap produces two distinct module instances once the installer hoists one and nests the other, and which one an import reaches depends on the directory it resolves from.
Does npm dedupe alone fix a duplicate, or do I need to change ranges first?
npm dedupe only collapses copies that the current ranges already permit — it hoists a nested version when a broader range higher in the tree accepts it. If two workspaces declare genuinely incompatible ranges (^18.3.0 versus exactly 18.2.0), no single version satisfies both and dedupe cannot merge them. Align the ranges first so one version is legal for every requester, then run dedupe to realise the collapse.
When should I use overrides instead of aligning workspace ranges?
Use an override only when the second copy is pulled by a transitive dependency you do not declare — a plugin that hard-depends on an older version your manifests never mention. Range alignment cannot reach a transitive requester, so a root overrides (or pnpm.overrides / Yarn resolutions) block is the tool that rewrites every matching node. Pin to a version that still satisfies the dependents' peer requirements, or you trade a duplicate for an unmet-peer warning.
How do I stop the duplicate from silently returning after I fix it?
Gate it in CI. Run npm ci so the install comes strictly from the lockfile, then assert with a manifest linter like syncpack list-mismatches that no dependency carries different ranges across workspaces, failing the pull request on any mismatch. Pairing the strict install with the mismatch check catches both a hand-edited range that was never re-resolved and a newly divergent range before either reaches the default branch.