Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Dependency Resolution Explained

Every npm install, pnpm install, or yarn install runs the same fundamental job: take a set of declared version ranges, walk the registry metadata, and collapse the result into one concrete, reproducible tree on disk. When that process goes wrong you get ERESOLVE failures, duplicate copies of React, phantom imports that work locally but break in CI, and bundles bloated with two versions of the same library. This guide explains the resolution algorithm itself — graph construction, semver intersection, deduplication, lockfile enforcement, and override mechanisms — and the configuration patterns that keep production installs deterministic.

Dependency resolution is the connective tissue of every other task in Core JavaScript Package Workflows: the fields you declare in your manifest feed the resolver, the resolver writes the lockfile, and the installed tree determines whether your module loads at runtime at all.

Dependency resolution pipeline Manifest ranges feed graph construction and semver intersection, which deduplicates into a lockfile and finally an installed node_modules tree. package.json declared ranges ^1.2.3 · workspace:* build graph DAG of every range request semver intersect highest version in every overlap dedupe + hoist one copy where ranges agree lockfile pinned + sha512 integrity hashes node_modules installed verbatim
Ranges resolve once into a deduplicated, hash-pinned lockfile; subsequent installs replay that lockfile verbatim into node_modules.

How resolvers build the dependency graph

A resolver never looks at a single package in isolation. It treats your project as the root of a directed acyclic graph (DAG) and visits every dependency edge — direct and transitive — collecting each version range requested for every package name. For a name requested by multiple parents, the resolver computes the intersection of all the ranges and picks the highest published version that satisfies every constraint simultaneously. If the intersection is empty, it either nests an additional copy deeper in the tree or fails loudly, depending on whether the constraint is a regular dependency or a peer dependency.

This is why two packages that each depend on lodash@^4 end up sharing a single hoisted copy, while one package on react@^17 and another on react@^18 force the resolver to choose between nesting duplicates or aborting. The split between resolvable duplication and hard failure is exactly the dividing line covered in Fixing npm ERESOLVE Peer Dependency Conflicts.

Resolution topologies and node layouts

Package managers materialize the resolved graph onto disk using distinct filesystem topologies. The layout directly impacts module loading, disk I/O, and runtime isolation.

Topology Manager Behavior Security / performance impact
Flat / hoisted npm (v3+), yarn Classic Deduplicates by hoisting shared versions to the root node_modules. Fast installs, but enables phantom dependencies (importing unlisted packages).
Strict symlinked pnpm Stores packages in a content-addressable store; symlinks them into node_modules/.pnpm. Zero phantom dependencies, minimal disk usage, strict isolation.
Plug'n'Play yarn Berry Replaces node_modules with a virtual filesystem (.pnp.cjs). Eliminates duplicate installs, requires a runtime loader.

Choosing and pinning a resolver

Resolution is only deterministic if every machine runs the same resolver. Pin the package manager itself with the packageManager field so Corepack provisions an identical version locally and in CI:

{
  "packageManager": "pnpm@9.7.0",
  "engines": {
    "node": ">=18.18.0"
  }
}
# Enable Corepack so the pinned manager is used automatically
corepack enable
corepack prepare pnpm@9.7.0 --activate

# Confirm the active resolver matches the manifest
pnpm --version

Without a pinned packageManager, a contributor on a newer npm or a CI image with a different default can produce a subtly different tree from the same package.json, defeating the lockfile's guarantee before it is even written.

Inspecting the resolved layout

# npm: view the flattened tree and explain why a package is present
npm ls --depth=0
npm explain lodash   # replace with the package you are investigating

# pnpm: trace symlinked resolution paths
pnpm why lodash
pnpm list --recursive --depth=0

# yarn (PnP): inspect virtual filesystem mappings
yarn why lodash
yarn explain peer-requests

Enforce strict isolation in CI so that local hoisting artifacts never mask a missing dependencies declaration. pnpm or Yarn PnP are the safest defaults for projects that need deterministic module boundaries.

Semver constraint evaluation and range matching

Before graph construction completes, the resolver parses every version specifier against registry metadata — evaluating dist-tags, pre-release identifiers, and deprecation flags. Correctly authoring these ranges depends on the field precedence documented in Understanding package.json Fields, and the classification of each range (runtime vs build-time vs peer) is the subject of When to Use peerDependencies vs devDependencies.

Constraint parsing matrix

Specifier Resolution behavior Use case
^1.2.3 >=1.2.3 <2.0.0 Standard library updates (minor/patch safe)
~1.2.3 >=1.2.3 <1.3.0 Patch-only updates
1.2.3 Exact match Critical security pins, known-breaking APIs
>=1.0.0 <2.0.0 Explicit range Fine-grained compatibility windows
workspace:* Local symlink Monorepo internal packages

A subtle trap: ^0.x.y does not widen to the next minor. For versions below 1.0.0, caret behaves like tilde — ^0.2.3 resolves to >=0.2.3 <0.3.0 — because the semver spec treats every 0.x release as potentially breaking. Library authors who ship 0.x should communicate this explicitly so consumers do not assume minor-level compatibility.

Pre-release and deprecated version handling

# Surface deprecated or vulnerable transitive dependencies
npm audit --omit=dev
pnpm audit --json | jq '.advisories[] | select(.severity=="high")'

# Force resolution to the latest stable dist-tag, ignoring pre-releases
npm install lodash@latest    # replace lodash with your package
pnpm add lodash@latest
yarn add lodash@latest

Avoid * or latest in package.json for production dependencies. These specifiers bypass lockfile determinism and admit unvetted transitive updates on every fresh install.

Deduplication and avoiding duplicate copies

When ranges overlap, a resolver collapses them to a single shared copy — that is deduplication. When they do not overlap, it keeps multiple copies, and that is where bugs appear. Two copies of a stateful singleton library (React, an event emitter, a context provider) mean two separate module instances, two separate caches, and Invalid hook call or Cannot read context errors that no amount of code review will catch.

# Detect duplicate copies of a single package across the tree
npm ls react
pnpm why react
npm dedupe          # rebuild the tree preferring shared copies
pnpm dedupe

npm dedupe and pnpm dedupe re-flatten an existing tree, but they can only collapse versions whose ranges actually intersect. When two consumers genuinely pin incompatible majors, you must reconcile the ranges or force a single version with an override. The full diagnostic and repair workflow for the most common instance of this — multiple React copies — is covered in Deduplicating Duplicate React Versions.

Deterministic lockfile enforcement and integrity checks

The lockfile is the cryptographic single source of truth for a reproducible build. Resolvers cross-reference each lockfile entry against registry metadata during installation, failing fast on checksum mismatches or unauthorized version bumps. Treating the lockfile as a first-class, reviewed artifact is the core of robust Lockfile Management Strategies.

Lockfile integrity verification

# Install strictly from the lockfile; fail on any drift
npm ci
pnpm install --frozen-lockfile
yarn install --immutable

GitHub Actions enforcement

- name: Install dependencies (strict)
  run: |
    if [ -f "package-lock.json" ]; then
      npm ci --audit=false --fund=false
    elif [ -f "pnpm-lock.yaml" ]; then
      pnpm install --frozen-lockfile
    elif [ -f "yarn.lock" ]; then
      yarn install --immutable
    fi

- name: Verify the dependency tree resolves
  run: |
    npm ls --depth=0 --parseable > /dev/null || echo "Dependency tree contains unmet constraints"

Never hand-edit a lockfile. Always regenerate integrity hashes (sha512-...) through the CLI (npm install, pnpm add, yarn up). Run npm audit --omit=dev or pnpm audit in PR pipelines to block merges that introduce known CVEs.

Workspace protocol and peer dependency resolution

Monorepo resolvers prioritize local workspace protocols over remote registry fetches. When peer dependencies are unmet, the engine either auto-installs a compatible version or throws a strict error depending on configuration. Scoping shared dependencies correctly prevents both duplication and the ERESOLVE failures that block installs entirely.

npm: overrides

Force a specific transitive version across the entire tree, bypassing upstream semver constraints.

{
  "overrides": {
    "minimist": "1.2.8",
    "semver": "^7.5.4",
    "**/axios": "1.6.0"
  }
}

pnpm: strict peer enforcement

Configure .npmrc to auto-install fallbacks while keeping strict graph validation.

auto-install-peers=true
strict-peer-dependencies=true

Add packageExtensions under the root package.json pnpm field to inject missing peer declarations into upstream packages without altering their resolved version:

{
  "pnpm": {
    "packageExtensions": {
      "react-router-dom@*": {
        "peerDependencies": {
          "react": "*"
        }
      }
    }
  }
}

Yarn: selective resolution

Pin specific versions regardless of upstream ranges using resolutions.

{
  "resolutions": {
    "lodash": "4.17.21",
    "**/axios": "1.6.0",
    "react": "18.2.0"
  }
}

Workspace linking and graph construction

{
  "workspaces": ["packages/*", "apps/*"]
}

Link internal packages with an explicit protocol in each package's package.json:

{
  "dependencies": {
    "@scope/ui": "workspace:*",
    "@scope/utils": "workspace:^"
  }
}

Always use workspace:* or workspace:^ for internal packages. Without the explicit protocol, the resolver treats the dependency as remote and fetches a stale published version, silently breaking the local development loop.

Security and isolation during resolution

Resolution is the first point at which untrusted code can touch your machine. The moment an install runs, lifecycle scripts (preinstall, install, postinstall) from every dependency in the tree may execute with your shell's privileges. A compromised transitive package can exfiltrate environment variables or write to your home directory before a single line of your own code runs.

# Resolve and install without running any lifecycle scripts
npm ci --ignore-scripts
pnpm install --frozen-lockfile --ignore-scripts

# Allow scripts only for an explicit allowlist (pnpm)
# pnpm.onlyBuiltDependencies in package.json restricts which packages may build
{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "@swc/core"]
  }
}

Combine --ignore-scripts in CI with an explicit allowlist for the handful of native packages that genuinely need a build step (esbuild, sharp, @swc/core). This narrows the attack surface from "every package in the tree" to a list you can audit. Pair it with npm audit/pnpm audit gates so a freshly resolved tree cannot introduce a known CVE without failing the pipeline.

Common pitfalls and anti-patterns

Mistake Impact Resolution
Importing packages not in dependencies (phantom deps) Works locally via hoisting, fails in isolated CI. Declare every imported package; install with pnpm to surface phantoms.
Using * or latest for production deps Bypasses lockfile determinism; unvetted transitive updates. Pin with ^/~ ranges and commit the lockfile.
Hand-editing package-lock.json / pnpm-lock.yaml Checksum validation fails on npm ci / --frozen-lockfile. Regenerate via CLI only.
Suppressing peer warnings with --force Multiple incompatible copies of react/react-dom load at runtime. Reconcile ranges or use a single override; see the ERESOLVE guide.
Omitting workspace:* for internal packages Resolver fetches a stale registry version, ignoring local source. Always declare the workspace: protocol.

Frequently Asked Questions

How do package managers resolve conflicting version ranges for the same transitive dependency? The resolver builds a directed acyclic graph and computes the intersection of every range requested for that package name, then selects the highest published version satisfying all of them. If the intersection is empty, regular dependencies get an additional nested copy installed in an isolated path, while peer dependencies trigger a hard ERESOLVE failure that must be reconciled.

What is the difference between overrides (npm), resolutions (yarn), and packageExtensions (pnpm)? overrides and resolutions forcibly replace a transitive dependency's resolved version across the whole tree, bypassing semver. packageExtensions does not change any resolved version — it injects missing peerDependencies (or dependencies) into an upstream package's manifest so the graph validates without patching the package itself.

How can I enforce strict dependency resolution in CI/CD pipelines? Install from the lockfile only — npm ci, yarn install --immutable, or pnpm install --frozen-lockfile — so any drift between manifest and lockfile fails the build. Combine that with strict-peer-dependencies=true in pnpm (or npm v7+'s default strict peer resolution) to fail on unmet peers, producing predictable, auditable trees.

Why does my monorepo resolve to a stale registry version instead of my local workspace package? The resolver links a local workspace package only when the workspace: protocol is declared in the dependency field, e.g. "@scope/pkg": "workspace:*". Without it, the package is treated as remote and the resolver fetches the latest published version matching the range, ignoring your local source.

Does npm dedupe always remove duplicate copies? No. It can only collapse copies whose version ranges actually intersect into a single shared version. When two consumers pin genuinely incompatible majors, dedupe leaves both copies in place; you must reconcile the ranges or force one version with an override.

Related

Core JavaScript Package Workflows