You're running a monorepo with 34 workspace packages. You've been trying to upgrade webpack from v4 to v5 for three months. Every time you get close, something in the peer dependency chain blocks you. Package A requires acorn@^7. Package B requires acorn@^8. Neither the package authors nor semver can resolve this for you — it's your constraint satisfaction problem now.
Semver promises that minor and patch upgrades are backward-compatible. At small scale, this mostly holds. At monorepo scale with 200+ transitive packages and multiple workspace packages with shared deps, it's a polite fiction with a lot of asterisks — and the asterisks tend to be the ones that block major upgrades for months.
The two types of version conflict
Version conflicts come in two distinct categories that require different resolution approaches:
Hard conflicts occur when two packages in your dependency tree require ranges for the same transitive package that don't intersect at any published version. Package A requires foo@^1.0 and Package B requires foo@^2.0. There is no version of foo that satisfies both constraints simultaneously. npm resolves this by hoisting one version and installing the other as a nested duplicate inside the requiring package's node_modules. Sometimes this is harmless. Sometimes the two instances of foo maintain separate state — singleton patterns, class identity checks with instanceof, event emitter registries — and you get runtime failures that only appear under specific execution paths or under production load that your test suite didn't exercise.
Soft conflicts are more insidious. The version ranges technically intersect — there's a version that satisfies both constraints — but the resolved version has behavior differences that break your code at runtime. foo@^1.0 resolves to [email protected] because that's the highest compatible version. Your code was written against [email protected], and the behavior changed in a way the semver minor bump didn't capture at 1.3.0. The lockfile install succeeds. The unit tests pass because they don't exercise the affected code path. Production breaks under load on a Tuesday afternoon.
We're not saying soft conflicts are impossible to avoid — thorough integration tests and explicit version pinning reduce the exposure. But in a large graph where you're not pinning every transitive dep (which is generally the right call for maintainability), soft conflicts are the ones that ambush you.
Finding the origin of a conflict
The first question when debugging a version conflict is: which package in my tree is responsible for imposing this constraint? For a project with 60 direct deps, you might find it manually. For a monorepo with 200+ direct and thousands of transitive packages, you need to query the graph.
Grepping through package-lock.json is error-prone and slow — the lockfile format nests dependencies in a way that makes ancestry tracing non-obvious, and the same package name can appear at multiple levels of the nesting without a clear indication of which direct dep owns each subtree.
The structured approach is to query the resolved dependency graph for all paths from your direct dependencies to the conflicting transitive package:
$ depswright conflicts --package acorn
Scanning full dependency graph for version conflicts...
acorn — 2 conflicting paths found
[email protected] → acorn@^8.8.2 (resolved: 8.10.0)
[email protected] → acorn@^7.4.1 (resolved: 7.4.1)
Conflict: webpack requires >=8, eslint requires ^7
Resolution: npm hoisted [email protected]; eslint receives its own copy at 7.4.1
Bundle impact: +47KB (duplicate parse tree in devDependencies only)
Now you know: this is an acorn conflict between webpack's peer requirement and eslint's peer requirement. Both are major version constraints. The resolution options are concrete. And because both are devDependencies that don't ship to users, the duplicate is likely acceptable — which is information you couldn't confirm without the full ancestry path.
The resolution triage ladder
When you identify a conflict, work through resolution options in order of increasing cost and increasing maintenance burden:
1. Check if one side can be updated
Often the constraint on one side is stale — the package has since released a version that updated its own peer requirement to be compatible with the other side. npm outdated combined with a changelog scan tells you in minutes whether this is possible. This is the free fix: update one dep, constraint disappears, no overrides needed.
2. Find the narrow intersection
What looks like a conflict sometimes has a version intersection that's easy to miss when comparing range specs mentally. If Package A requires foo@^1.5.0 and Package B requires foo@<2.0.0 >=1.4.0, any 1.x version at or above 1.5.0 satisfies both. Pinning to [email protected] satisfies the constraints without an override. This requires reading the actual semver ranges in the lockfile rather than assuming from the major version numbers that no intersection exists. Document the pin with a comment.
3. Use overrides / resolutions as the deliberate escape hatch
npm v8+ supports overrides in package.json. Yarn Berry supports resolutions. Both let you force a specific version regardless of what any sub-dep requires:
{
"overrides": {
"acorn": "^8.10.0"
}
}
This is the right tool when you cannot update either of the packages creating the conflict and the duplicate-version behavior is unacceptable. Use it deliberately and document every override explicitly — what conflict it resolves, what version was blocked, and what breaks if the override is removed. Undocumented overrides become traps for the next engineer who runs npm update and doesn't understand why certain things are pinned.
4. Accept the duplicate with explicit documentation
For build-time tools and packages where two instances don't share runtime state, duplicate versions coexisting in node_modules is acceptable. Check the bundle size impact before committing to this path — some packages are large enough that duplication has a real cost on client-side bundle sizes. For CLI tools and dev dependencies that don't ship to users, duplication is usually the right call.
The monorepo-specific constraint problem
Monorepos multiply version conflict surface area in a predictable way. Each workspace package can introduce its own set of transitive dependencies. When multiple workspace packages share a transitive dependency — which they almost always do — the resolved version must satisfy the combined requirements from all consumers simultaneously. With 30+ workspace packages, this is a constraint satisfaction problem across thousands of variables, and npm's hoisting algorithm solves it silently at install time.
The problem is that the resolution npm chooses isn't documented anywhere except the full lockfile. You get a node_modules structure that satisfies the constraints, but understanding why any specific version was resolved requires reading the lockfile's resolution tree in full — something that isn't practical manually and that most engineers never do except when debugging an incident.
The useful question to ask in CI isn't "did install succeed?" — it almost always does, with silent duplication handling conflicts. It's "are there packages that have been duplicated at incompatible major versions, and does that duplication affect runtime behavior or just build time?" That question requires analyzing the graph, not just running npm install and checking the exit code.
What to track across lockfile changes
Version conflicts in a monorepo aren't a one-time problem you solve and archive. They're a state that shifts as packages release new versions and as you add workspace packages. The constraint set that was satisfied cleanly this week may not be next week after a batch of dep updates.
Three things worth tracking structurally across lockfile diffs:
- Deduplication changes. When a package switches from a single hoisted version to a nested duplicate in a lockfile diff, that's a new constraint that didn't exist before. It may be harmless. It may not be. The diff should surface it explicitly rather than burying it in thousands of lines of lockfile churn.
- New peer dep warnings. npm's peer dependency warnings scroll past during install and are easy to dismiss. Capturing them structurally — as structured output that CI can diff against the previous PR state — surfaces new conflicts at the moment they're introduced rather than when they eventually cause a runtime failure.
- Override staleness. An override pinned six months ago to unblock a specific upgrade may no longer be necessary if the packages involved have released compatible versions. It may also now be pinning to a version with a known CVE. Overrides should be reviewed periodically as part of dep maintenance, not treated as permanent configuration.
The engineers who rarely get blocked by version conflicts are the ones who catch new conflicts at the PR that introduced them — not three months later when a major upgrade attempt surfaces the full accumulated scope of constraints that have been quietly building in the lockfile.
Depswright generates a full conflict report on every scan, with ancestry paths for every conflict. Set it up free.