npm Could Not Resolve Dependency? Fix ERESOLVE
Resolve npm ERESOLVE peer conflicts by upgrading the stale blocker first, pinning scoped overrides next, and deduping leftovers last..
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Basic npm install and package.json knowledge
- ✓Comfort reading terminal error output
- ✓A Node project with dependencies to inspect
- ERESOLVE means two packages demand incompatible versions of the same peer — read the conflict chain to find the blocker
- Upgrade the outdated package first since new releases usually widen peer ranges to match the ecosystem
- Use npm overrides for a surgical pin when upgrades can't ship today, and npm dedupe to collapse duplicates
- For deep peer-tree forensics, see the npm-eresolve-dependency-tree guide linked in related reading
Picture planning a group dinner where one friend only eats vegan and another booked a steakhouse. Both demands are valid alone, but together no restaurant satisfies everyone — so the booking fails. That's an npm peer conflict. Two packages you depend on each insist on a different version of a shared third package, and npm refuses to install a combination it knows will break. The fix isn't forcing the booking; it's updating one friend's constraints or picking a compromise everyone accepts.
You run npm install and instead of a clean tree you get a wall of ERESOLVE text: could not resolve dependency, peer react@"^17.0.0" from some-library, conflicting peer react@"^18.0.0" from another. The output names five packages and three version ranges, and every suggestion online says --legacy-peer-deps, which feels like typing sudo to silence a warning. It installs — and your app crashes at runtime with a hooks error that takes a day to trace.
Peer conflicts are npm keeping a promise: it won't assemble a dependency tree it can prove is broken. The conflict output is a diagnosis, not noise. It names the exact packages whose requirements collide and the ranges that don't overlap. Learning to read that chain turns a 30-minute panic into a 5-minute decision between upgrading the blocker, pinning an override, or replacing the stale package.
This guide teaches the read-first workflow: find the blocker, upgrade it, override surgically when you can't, and dedupe the result. You'll also learn why --legacy-peer-deps is a loan with runtime interest, and when the deep-dive companion guide earns its click.
Reading the Conflict Chain: Find the Blocker
ERESOLVE output reads like a stack trace: start at the bottom. npm names the package it tried to place, the peer range that candidate violates, and the dependent imposing that range. Walk down until you find the package with the oldest publish date and the narrowest range — typically a library pinning a previous major (react@^17 while the world ships 18). That single package usually explains the entire wall of text above it; everything else is collateral.
Confirm with the registry before deciding. npm view <pkg> versions shows whether a newer release exists; npm view <pkg>@latest peerDependencies shows whether it widened its range. If the newest release still pins the old major, the package is abandoned and replacement beats upgrade. Spending ten minutes reading the chain saves hours of flag-and-pray cycles — the blocker is almost always one stale dependency, not a systemic ecosystem failure.
npm explain is the magnifying glass for confirmed blockers. Run npm explain <package> to see every path through which it enters the tree — direct, transitive, peer — with the versions each parent requests. When the same package appears under three parents with three ranges, the narrowest oldest one is your upgrade target and the others are context. Combine with npm view time data (npm view <pkg> time --json shows release dates) to judge maintenance health: a blocker untouched for two years won't widen its peers next week, so replacement planning starts now. Save the full ERESOLVE output plus explain traces in the upgrade ticket; the next engineer inherits evidence instead of archaeology. Reading chains carefully resolves most walls to one stale package — and the ticket proves it wasn't guesswork.
Upgrade the Blocker: The Fix That Stays Fixed
When the blocker has a newer release supporting your major, upgrade it. This is the only fix that removes the conflict instead of suppressing it: the new peer range overlaps your tree, strict install passes, and future npm operations stay clean. Budget for API renames honestly — major bumps earn their peer widening with breaking changes, so codemod call sites in the same PR and run the full suite before merging.
Verify the upgrade actually unified the tree. npm ls <singleton> must show exactly one copy; du -sh node_modules before and after quantifies the dedupe win. If the upgrade introduces its own conflicts, you're peeling a stale layer cake — upgrade the next blocker the same way. Two or three iterations retire years of drift. For genuinely tangled nested peer trees where the chain loops through multiple packages, the companion npm-eresolve-dependency-tree guide covers full arborist forensics step by step.
Codemods make major bumps shippable inside one sprint. Most popular libraries ship migration guides with rename mappings; jscodeshift scripts apply them across hundreds of call sites in minutes, and the full suite confirms behavior preservation. When no codemod exists, wrap the migration: introduce the new version behind a compatibility shim in one PR, migrate call sites incrementally, then delete the shim — each step stays green and reviewable. Budget suite time honestly: peer upgrades touch shared components, so run the complete test matrix including integration and visual tests, not just unit scope. Verify the unified tree afterward with npm ls on every singleton your app depends on (react, styled-components, state managers), because a second duplicate hiding behind the first fix wastes the whole exercise. Two or three blocker upgrades retire years of drift and unblock every future bump.
Overrides: Surgery When You Can't Upgrade Today
Sometimes the upgrade can't ship this week — a rename touches 200 files, or the release train leaves Friday. npm overrides let you pin a resolution explicitly: "overrides": { "react": "^18.2.0" } forces every subtree to resolve React 18 regardless of declared peers. Unlike command-line flags, overrides are version-controlled, reviewable, and visible to every install — the conflict decision lives in the repo, not in someone's shell history.
Use them narrowly and temporarily. Over-scope the pin and you'll force versions onto packages that genuinely need the old one, trading a solver error for runtime breakage. Scope to the subtree when possible ("overrides": { "ui-date-picker": { "react": "^18.2.0" } }) and attach the upgrade ticket as a code comment. Revisit quarterly: each override is maintenance debt that can block future upgrades exactly like the stale peer it bypassed.
Version-control the reasoning, not just the pin. Every overrides entry deserves an inline comment with the blocked upgrade ticket, the date added, and the removal condition (blocker reaches version X) — future upgrades then read intent instead of guessing whether the pin is still load-bearing. Review overrides in dependency-update PRs explicitly: Renovate and Dependabot surface new blocker releases, and each one is a chance to delete a pin. Measure override scope with npm ls before and after to confirm the pin affects only the stale subtree; an over-broad pin that drags unrelated packages along shows up as unexpected version shifts in the lockfile diff. Teams that calendar quarterly override reviews delete most pins within two quarters. The ones that skip reviews accumulate pins that eventually conflict with each other — overrides fighting overrides, with the solver caught in the middle.
npm dedupe: Collapse the Leftovers
Partial upgrades leave duplicate transitive copies scattered through the tree — same library at two versions, both technically satisfying their parents. npm dedupe re-solves placement to hoist the maximum shareable set, collapsing duplicates whose ranges overlap. It's the cleanup pass after the real fix, not a fix itself: dedupe can't merge truly disjoint ranges, so run it after upgrading the blocker, not instead of it.
Measure before and after. npm ls <name> --all shows the duplicate set; du -sh node_modules quantifies the bloat (hundreds of megabytes is common in flag-era trees). Commit the resulting lockfile so CI reproduces the collapsed tree exactly. If dedupe reports it can't hoist, read which ranges stay disjoint — that's your next blocker to upgrade, identified for free.
Lockfile discipline makes dedupe results stick. Always run dedupe with the lockfile present and commit the result in the same PR as the upgrade, so CI's npm ci reproduces the collapsed tree exactly — dedupe without a committed lockfile collapses locally and diverges remotely. Review the lockfile diff for surprise removals: dedupe occasionally drops a nested copy some deep consumer relied on implicitly, and the suite (not the solver) is the backstop that catches it. For monorepos, run dedupe per workspace plus at the root; workspace boundaries hoist differently than flat trees, and a root-only pass leaves workspace duplicates in place. Track node_modules size and install duration as pipeline metrics — sudden growth after a dependency PR signals new duplicates worth collapsing. Dedupe is maintenance, not magic: schedule it after every upgrade PR and the tree stays lean instead of accumulating flag-era bloat.
Why --legacy-peer-deps Is a Loan With Interest
The flag restores npm 6 semantics: peers install unenforced, the solver stays quiet, and CI goes green. The debt comes due at runtime. Unchecked peers let incompatible copies coexist — duplicate React breaking hooks, duplicate styled-components splitting themes, duplicate state managers forking stores. Each failure looks like an app bug, complete with misleading stack traces, and none of them reproduce in the solver output you silenced.
If you must use it as a Friday-night bridge, contain it: apply to one install, file the upgrade ticket in the same hour, and add a CI check that fails on the flag's presence so it can't become permanent. Audit existing repos for .npmrc files carrying legacy-peer-deps=true — silent repo-level flags are how teams forget they're bypassing the solver for months. The flag's honest name would be --break-quietly-later.
Detecting silent bypasses requires active hunting. Grep workflows, Dockerfiles, .npmrc files (repo, home, and CI-global), and package.json scripts for legacy-peer-deps and --force — each hit is an install the solver isn't checking. npm config list surfaces effective settings including global and environment-sourced flags that per-file greps miss. Replace each bypass with a dated plan the same week: upgrade ticket for supported blockers, scoped override with expiry for blocked ones, replacement spike for abandoned packages. Add the CI gate from the incident (fail on flag presence) so removed bypasses stay removed — without the gate, the next Friday-night deploy re-adds the flag and the cycle restarts. Report bypass count as a health metric in dependency reviews; zero is the only acceptable steady state, and every temporary bridge should name the engineer responsible for burning it down.
When to Open the Deep-Dive Companion Guide
Most conflicts end at the blocker upgrade. Open the npm-eresolve-dependency-tree deep dive when the chain loops through three or more packages, when overrides cascade (fixing one conflict reveals another), or when monorepo workspace ranges interact with root pins. The companion covers npm arborist internals: reading ideal-tree reification logs, using npm explain to trace why a version was chosen, and structuring workspace peer ranges so siblings don't fight.
Bring evidence when you go: the full ERESOLVE output, npm explain <pkg> for each disputed copy, and your lockfile diff. Deep forensics without the explain output is guesswork. And keep this article's order of operations — blocker, upgrade, override, dedupe — as the first pass even on tangled trees. Half of supposedly deep conflicts still resolve to one abandoned package once you read the chain carefully.
Preparing evidence before escalating saves the deep-dive session. Capture the complete ERESOLVE output (not excerpts — the chain head matters), npm explain for each disputed copy, npm ls --all for the full tree, and the lockfile diff of the change that introduced the conflict. Note which upgrades you already attempted and their outcomes; the companion guide's forensics assume the single-blocker pass is done. In the session, work bottom-up through nested chains one collision at a time, pinning each resolution with overrides as you go so partial progress commits incrementally. Time-box the forensics: if three sessions haven't untangled the tree, the answer is usually replacing the worst offender with a maintained alternative rather than solving a five-body problem. Evidence-first escalation resolves most tangles in one sitting — the logs do the talking and the team stops guessing.
A Stale Date Picker Blocked All Deploys for 5 Hours
- Install-time strictness exists to prevent runtime crashes. Every bypass flag converts a 5-minute solver error into a multi-hour production debug with user impact.
- Duplicate React is never benign. Assert a single copy with npm ls react in CI — hooks identity depends on module identity, and two copies always break eventually.
- Gate against the escape hatches themselves. A one-line CI check for legacy-peer-deps would have blocked the flag PR and forced the real upgrade on Friday.
| File | Command / Code | Purpose |
|---|---|---|
| npm install 2>&1 | tail -40 | Reading the Conflict Chain | |
| npm install -S ui-date-picker@^4.1.0 | Upgrade the Blocker | |
| package.json (excerpt) | { | Overrides |
| npm ls lodash --all | head -30 | npm dedupe | |
| grep -rn 'legacy-peer-deps\|force' .github/workflows/ .npmrc ~/.npmrc package.js... | Why --legacy-peer-deps Is a Loan With Interest | |
| npm explain react 2>&1 | head -30 | When to Open the Deep-Dive Companion Guide |
Key takeaways
Common mistakes to avoid
6 patternsAdding --legacy-peer-deps as the first response
Pinning overrides repo-wide instead of per-subtree
Running npm install in CI instead of npm ci
Skipping npm ls verification after the fix
Leaving legacy-peer-deps=true in .npmrc
Upgrading without running dedupe and committing the lockfile
Interview Questions on This Topic
What does ERESOLVE actually mean, in one minute?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Node.js. Mark it forged?
7 min read · try the examples if you haven't