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.

Resolver graph construction From declared ranges through the SAT-style solver to a flattened installed tree. declared ranges ^, ~, workspace: constraint solver satisfy every edge dedupe pass hoist compatible installed tree node_modules / store
How declared ranges collapse into one installed tree.

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.

Resolution is fundamentally a constraint-satisfaction problem: every package in the tree declares a range for each of its dependencies, and the resolver must find a single set of concrete versions that satisfies every constraint simultaneously. It starts from your direct dependencies, walks their transitive requirements, and for each package collects all the ranges anyone asked for, then picks a version that satisfies as many as possible with one copy — installing a second copy only when no single version can satisfy conflicting ranges. The installed tree is the solution to that constraint system, not a simple flattening of what you declared.

The package managers differ in how they materialize that solution on disk, which changes what bugs surface. npm and Yarn classic hoist compatible versions to a flat node_modules, which is fast to resolve but allows phantom dependencies — a package importing something it never declared because a sibling hoisted it. pnpm builds a strict, symlinked tree from a content-addressed store, so a package can only import what it declared, which surfaces phantom dependencies immediately and prevents accidental duplication. The same constraint solution, materialized strictly, is simply harder to get subtly wrong.

It helps to distinguish declaration from installation. What you write in package.json is a set of constraints — ranges the resolver must satisfy — not the tree itself; the installed node_modules is the resolver's solution to those constraints, chosen to satisfy every edge with as few duplicate copies as possible. This is why two projects with identical manifests can end up with slightly different trees if their lockfiles differ, and why the lockfile matters so much: it pins the specific solution so a second install reproduces it exactly rather than re-solving and possibly choosing differently as new versions are published.

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.

Semver range operators How caret, tilde and exact ranges admit versions. Range Admits Blocks ^1.2.3 1.x ≥ 1.2.3 2.0.0 and up ~1.2.3 1.2.x patches 1.3.0 and up 1.2.3 that exact build everything else
Each operator widens or narrows the set of admissible versions.

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.

The range operators are the vocabulary consumers use to express how much drift they accept, and their exact semantics decide which versions the resolver may pick. A caret allows changes that do not modify the left-most non-zero version component, so ^1.2.3 admits any 1.x at or above 1.2.3 but not 2.0.0, while ^0.2.3 admits only 0.2.x because for pre-1.0 versions the minor is treated as breaking. A tilde allows patch-level changes, and an exact version admits only itself. Understanding these precisely is what lets you predict, rather than discover, which version an install will resolve.

Pre-release versions add a subtlety that trips people up. A range like ^1.2.3 does not match 1.3.0-beta.1 unless the range itself includes a pre-release tag, because pre-releases sort below their stable counterpart and are treated as opt-in. This is deliberate — it stops a caret range from silently pulling a release candidate into a production install — but it means testing a pre-release requires an explicit range or dist-tag, which is exactly the staged-rollout mechanism that lets early adopters exercise a version before it becomes the default.

One range subtlety causes recurring surprises: the caret treats the leftmost non-zero component as the compatibility boundary, so its behavior changes for pre-1.0 versions. ^1.2.3 admits any 1.x at or above 1.2.3, but ^0.2.3 admits only 0.2.x — because for a 0.x release the minor is treated as potentially breaking. This reflects the convention that pre-1.0 packages may break on minor bumps, and it means a dependency on a ^0.x package is far more tightly constrained than the same caret on a stable one, which is worth knowing when a 0.x dependency refuses to deduplicate with a sibling's slightly different range.

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.

Deduplication effect Before and after hoisting compatible versions into one copy. Duplicated • Two ranges, two copies • separate module identities • React context breaks Deduplicated • One compatible copy hoisted • single identity • context providers work
Compatible ranges collapse to a single shared copy; incompatible ones stay nested.
# 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.

Whether duplication happens at all is partly a function of how tightly your dependencies pin their own dependencies. A library that pins an exact version of a shared package forces a second copy for any consumer already on a different version, while a library that declares a reasonably wide range lets the consumer's resolver deduplicate to a single shared copy. This is why publishing libraries should keep their ranges wide — an over-constrained library imposes duplication on everyone downstream, which the consumer cannot fix without an override.

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.

Integrity verification Resolved version plus integrity hash checked on every install. resolved version exact tuple integrity hash sha512 subresource verify on install reject mismatch
The lockfile pins both the version and a tamper-evident hash.

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.

Enforcing the lockfile in CI is a one-line change with an outsized effect on reproducibility, refusing any drift between the manifest and the lockfile:

# npm: fails if package.json and package-lock.json disagree
npm ci --ignore-scripts
# pnpm: identical guarantee
pnpm install --frozen-lockfile --ignore-scripts
# yarn berry
yarn install --immutable

Each of these refuses to mutate the lockfile and exits non-zero on a mismatch, so a developer who bumped a dependency without regenerating the lockfile gets a red build rather than a silently non-reproducible one. The --ignore-scripts flag additionally neutralizes arbitrary lifecycle code from the dependency graph during install, closing a common supply-chain vector at the same moment you enforce reproducibility.

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.

Workspace protocol and peer dependency resolution Monorepo resolvers prioritize local workspace protocols over remote registry fetches. Workspace protocol and peer dependency resolution Monorepo resolvers prioritize local workspace protocols over remote registry fetches.
Workspace protocol and peer dependency resolution — the core idea of this section at a glance.

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.

Peer dependencies interact with resolution in a way that is easy to misread. A peer is not installed by the package that declares it; it is a requirement the consumer must satisfy, and the resolver uses the consumer's single copy to satisfy every peer that asks for a compatible range. This is precisely what keeps a framework single-instance across many plugins: each plugin declares the framework as a peer, and they all resolve to the one copy the application installed. Declaring the framework as a direct dependency instead breaks that, installing a plugin-local copy and doubling the framework.

In a workspace, the workspace: protocol resolves internal peers against the local packages, so a shared internal library used as a peer by several workspace packages resolves to the single local copy during development and to the published version afterward. This keeps the single-instance guarantee intact across the monorepo, which matters most for exactly the stateful, identity-sensitive packages — a shared React, a shared store — where a second copy would break context and comparisons.

Peer dependencies are frequently the source of an install-blocking conflict, and the modern resolvers surface it as an ERESOLVE error that names the conflicting requirements. The correct response is rarely --force or --legacy-peer-deps, which suppress the check and can leave you with a genuinely incompatible tree; it is to read the named requirements and reconcile them — widen a too-narrow peer range, upgrade a dependency to one that accepts the installed peer, or correct a misclassified dependency that should have been a peer in the first place. Suppressing the error trades a loud, actionable failure for a silent, latent one that surfaces at runtime as a duplicated framework or a missing hook dispatcher.

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.

Security and isolation during resolution Resolution is the first point at which untrusted code can touch your machine. Security and isolation during resolution Resolution is the first point at which untrusted code can touch your machine.
Security and isolation during resolution — the core idea of this section at a glance.
# 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.
Common pitfalls and anti-patterns Common pitfalls and anti-patterns in production JavaScript package workflows. Common pitfalls and anti-patterns Common pitfalls and anti-patterns in production JavaScript package workflows.
Common pitfalls and anti-patterns — the core idea of this section at a glance.

The costliest resolution anti-pattern is treating symptoms instead of causes — reaching for a blanket override or a forced upgrade to make an error go away without understanding what produced it. A peer-dependency warning usually means a real version mismatch that widening a range or correcting a misclassified dependency would fix properly; silencing it with an override can mask an incompatibility that surfaces later as a runtime failure. The methodical response is to inspect the graph, identify whether the problem is duplication, a peer conflict, or a phantom dependency, and apply the matching fix rather than the most convenient one.

Another common anti-pattern is over-constraining a published library's dependency ranges, which imposes duplication on every consumer downstream. A library that pins an exact version of a shared package forces a second copy for any consumer already on a different version, and the consumer cannot fix it without an override of their own. Publishing libraries should keep their ranges reasonably wide so consumers can deduplicate to a single shared copy, reserving tight pins for applications where the lockfile provides exactness and nothing depends on the declared ranges.

Duplication, deduplication, and single-instance guarantees

Duplication is the resolution outcome that causes the most confusing bugs, because it is invisible until something compares identities. When two packages depend on ranges that no single version satisfies, the resolver installs two copies, and for a stateless utility that is merely wasted disk. For a package whose identity matters — a framework whose context is keyed on the module instance, a validation library whose schemas are compared by reference, a plugin registry — two copies mean two independent identities, and symptoms follow that resist debugging: a provider that does not match its consumer, an instanceof that returns false for an object that visibly is that class.

Deduplication Two copies with split identity versus one shared copy. Duplicated • two ranges, two copies • separate identities • instanceof fails Deduplicated • one compatible copy • single identity • context works
Compatible ranges collapse to one copy; verify with npm ls that identity is single.

Deduplication collapses compatible copies back to one. npm dedupe and pnpm dedupe re-examine the tree and hoist packages to a shared version where the ranges permit, and an override forces a single version when you know one is compatible for all consumers. The verification is always the same: after deduplicating or overriding, run npm ls <pkg> or pnpm why <pkg> to confirm every path resolves to one physical copy where you expect it. The single-instance guarantee is not something you assume from a clean install — it is something you prove for the packages whose identity your application depends on.

Integrity, lockfiles, and reproducible resolution

Resolution is only reproducible if its output is pinned, which is the lockfile's job. A lockfile records, for every package in the solved tree, the exact resolved version, the integrity hash that verifies the downloaded tarball, the source it came from, and the dependency edges that required it — so a second install on another machine reproduces the identical tree rather than re-solving the constraints and possibly picking different versions as new releases appear. Without the lockfile, resolution is a moving target; with it, resolution happens once and is verified thereafter.

Lockfile pins Version, hash, source and edges pinned and verified. resolved version exact per package integrity hash tamper-evident frozen install fail on drift reproducible tree same everywhere
A lockfile pins the solved tree and verifies each tarball, making resolution reproducible and safe.

The integrity hash is what makes the pin tamper-evident. Each install checks the downloaded tarball against the recorded hash and refuses a mismatch, so a compromised or substituted package cannot silently enter the tree. This is why a frozen install against a committed lockfile is the foundation of both reproducibility and supply-chain safety: it proves the resolved graph is exactly the reviewed one, byte for byte, and any drift between the manifest and the lockfile fails the install rather than being silently reconciled. Resolution, pinned and verified, is what turns a set of ranges into a build you can trust to be the same everywhere.

Security and isolation during resolution

Resolution is a moment of trust: the resolver reaches out to registries, downloads tarballs, and assembles them into the tree your code will run. Hardening that moment starts with restricting where packages may resolve from. A lockfile-lint check that asserts every entry resolves from an allowed host catches a typosquat or an injected registry before the poisoned lockfile is merged, and pinning private scopes to a specific host closes the dependency-confusion vector where an internal name is resolved from an untrusted public package.

Security and isolation during resolution Resolution is a moment of trust: the resolver reaches out to registries, downloads tarballs, and assembles them into the Security and isolation during resolution Resolution is a moment of trust: the resolver reaches out to registries, downloads tarballs, and assembles them into the tree your code will run.
Security and isolation during resolution — the core idea of this section at a glance.

Install-time code is the other exposure. Lifecycle scripts run automatically during resolution with the installing user's privileges, so a compromised transitive dependency can execute a payload during a routine install — in CI, often with access to secrets. Running installs with ignored scripts and allow-listing only vetted native builds neutralizes that vector, while a frozen install guarantees the resolved graph is exactly the reviewed one. Resolution hardened this way — allowed hosts, verified hashes, no arbitrary execution, a frozen graph — turns the install from a trust exercise into something you can reason about precisely.

Diagnosing resolution problems methodically

When resolution goes wrong, the fastest path to a fix is to ask the resolver what it did rather than guess. npm ls <pkg> and pnpm why <pkg> print every path a package appears on and the ranges that pulled it in, which immediately reveals duplication (the same package at two versions) and its cause (which parents requested incompatible ranges). For a peer conflict, the error names the conflicting requirements directly, and the resolution is usually to widen a range, add an override, or correct a misclassified dependency.

Inspect, fix, verify Ask the resolver, apply the matching fix, re-inspect. npm ls / pnpm why see the graph apply matching fix dedupe / range / declare re-inspect prove the tree
Inspect-fix-verify turns opaque resolution failures into a debuggable loop.

The methodical sequence is: reproduce the installed state, inspect the graph for the package in question, identify whether the problem is duplication, a peer conflict, or a phantom dependency, and apply the matching fix — deduplicate or override for duplication, adjust ranges or peers for a conflict, declare the missing dependency for a phantom. Confirm the fix by re-inspecting the graph, not by assuming: a change that looks right in the manifest is only proven by the resolver producing the tree you expected. This inspect-fix-verify loop turns resolution from an opaque source of frustration into a debuggable system.

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.

Why does ^0.2.3 behave differently from ^1.2.3?

For pre-1.0 versions the caret treats the minor as breaking, so ^0.2.3 admits only 0.2.x, whereas ^1.2.3 admits any 1.x at or above 1.2.3. The convention reflects that 0.x releases are expected to break on minor bumps.

How do I confirm a package resolved to a single copy?

Run npm ls <pkg> or pnpm why <pkg> and check that every path shows the same version. Multiple versions in the output mean duplication — deduplicate or force a single version with an override, then re-check.

Why doesn't ^1.2.3 install a 1.3.0-beta.1?

Pre-release versions sort below their stable counterpart and are opt-in, so a stable range excludes them unless it includes a pre-release tag. This stops a caret from silently pulling a release candidate into production; test pre-releases with an explicit range or dist-tag.

Why does a library pinning exact versions cause me duplication?

An exact pin forces a second copy for any consumer already on a different version, because no single version satisfies both. Libraries should keep ranges reasonably wide so consumers can deduplicate to one shared copy; an over-constrained library imposes duplication downstream.

How do I harden resolution against a malicious package?

Restrict resolution to allowed hosts with lockfile-lint, pin private scopes to a specific registry to close dependency confusion, run installs with --ignore-scripts to block install-time code, and use a frozen install so the resolved graph is exactly the reviewed one.

How do I keep dependency resolution predictable?

Commit and enforce the lockfile with a frozen install, pin the package manager so everyone resolves identically, keep library ranges reasonably wide so consumers deduplicate, and inspect the graph with npm ls/pnpm why after changes. Predictable resolution is pinned resolution plus honest ranges.

How do I diagnose a resolution problem?

Ask the resolver what it did: npm ls <pkg> or pnpm why <pkg> prints every path a package appears on and the ranges that pulled it in, which reveals duplication, peer conflicts, and phantom dependencies. Inspect, apply the matching fix, and re-inspect to confirm the tree resolved as expected.

Related

Core JavaScript Package Workflows