How npm Resolves Dependency Trees in JavaScript

This article provides a comprehensive breakdown of how npm constructs and resolves dependency trees in modern JavaScript projects. It covers the end-to-end resolution pipeline, including Semantic Versioning (SemVer) evaluation, dependency graph generation, tree flattening and hoisting mechanisms, conflict mitigation, and the role of the lockfile in ensuring deterministic installations.

1. Reading Manifests and Evaluating SemVer

The resolution process begins when you run npm install. npm inspects the root package.json file to identify the direct dependencies declared under dependencies, devDependencies, peerDependencies, and optionalDependencies.

For each declared package, npm evaluates the Semantic Versioning (SemVer) range (e.g., ^1.2.0, ~2.0.1, or >=3.0.0). It queries the npm registry to fetch package metadata (packument), identifying all available versions that satisfy the defined range. By default, npm selects the highest available version within the matching range.

2. Graph Construction with Arborist

Starting with npm v7, npm utilizes an internal library called Arborist to construct the dependency graph. Arborist traverses the dependencies recursively:

  1. Root Analysis: It loads the current project state and any existing node_modules and package-lock.json.
  2. Breadth-First Traversal: Arborist traverses dependencies breadth-first, fetching manifests for sub-dependencies (transitive dependencies) until the full dependency graph is mapped in memory.
  3. Peer Dependency Resolution: Modern npm automatically resolves and installs peerDependencies. Arborist ensures that peer requirements placed by child dependencies are compatible with versions supplied by sibling or ancestor packages.

3. Hoisting and Flattening the Tree

Historically (npm v2), dependencies were installed strictly nested, which caused deeply nested directory paths and massive duplication. Modern npm uses a flat dependency structure achieved through hoisting:

4. Conflict Resolution and Nesting

When two or more packages require incompatible versions of the same dependency, npm cannot place both at the root node_modules directory because path names would collide. To resolve this:

  1. The first resolved version is hoisted to the root /node_modules/ directory.
  2. The conflicting, incompatible version is nested directly inside the specific dependency’s own node_modules folder (e.g., /node_modules/package-a/node_modules/conflicting-package/).
  3. When Node.js resolves modules at runtime, its module resolution algorithm searches the nearest node_modules directory first, ensuring each package loads its compatible dependency version.

5. Determinism via package-lock.json

To prevent discrepancies across different environments or future installations, npm relies on package-lock.json: