How npm resolves transitive dependencies
When you run npm install, npm has to decide which version of every package in your dependency tree gets installed. For a flat dependency list this is straightforward. For a tree with hundreds of indirect dependencies, where the same package is requested by multiple parents at different version ranges, the resolution algorithm matters — both for build reproducibility and for security review.
This is a practical walk-through of how npm's resolver picks versions, what a lockfile records, and where the resolution surprises happen.
The inputs
npm's resolver starts with two things: your package.json (direct dependencies and their version ranges) and a fetched view of the registry's metadata for each named package (which versions exist, what each version's own dependencies are).
Version ranges are the SemVer ranges you write: ^1.2.3, ~1.2.3, >=1.0.0 <2.0.0, exact pins like 1.2.3. Every range expresses an interval; the resolver picks a single concrete version inside each interval.
The algorithm in outline
npm walks the dependency tree, picks the highest version of each package that satisfies all known constraints, and records the choices in package-lock.json. When a package is requested at incompatible ranges by different parents, npm allows multiple copies in the tree.
The simplest case is a single direct dependency:
"dependencies": { "lodash": "^4.17.0" }
npm fetches the registry metadata for lodash, picks the highest published version that matches ^4.17.0 (e.g. 4.17.21), records the resolved version and integrity hash in the lockfile, and installs it under node_modules/lodash.
The more interesting case is conflicting ranges. Suppose your direct dependency A declares lodash@^4.17.0 and your direct dependency B declares lodash@^3.0.0. Both ranges are valid for different majors. npm resolves this by dependency hoisting: it places one version of lodash (the one chosen for the higher-priority context) at the top level of node_modules, and nests the other inside the dependent that needed it (e.g. node_modules/B/node_modules/lodash).
This is why your real node_modules has packages at multiple depths.
Hoisting, not deduping
npm calls this hoisting; users sometimes confuse it with deduplication. The distinction matters: hoisting places a single resolved version at the top level when possible; nesting puts an alternate version deeper in the tree when the top-level choice is incompatible. There is no single global version per package across the tree — there's a global default and per-subtree overrides.
The practical implication: a security advisory for lodash@4.16.0 may apply to one nested copy of lodash and not to the top-level one. npm audit walks the actual tree, not just the top-level entries.
The lockfile is the contract
package-lock.json records the exact resolution npm produced. Each entry has:
- The resolved version
- A registry URL for that version's tarball
- An integrity hash (subresource integrity, e.g.
sha512-...) that the install will verify
When you run npm ci, npm installs exactly what the lockfile says. No re-resolution happens. This is the only mode that gives reproducible installs across machines.
When you run npm install, npm may add or update the lockfile if your package.json ranges have changed or if npm decides newer versions inside the same range are available. This is convenient but means two developers running npm install at different times can end up with different actual versions.
When the resolver surprises you
A few cases reliably surprise people.
peerDependencies are not auto-installed by old npm versions. npm 7+ installs them automatically. Older versions warned but did not. This is now mostly historical.
Optional dependencies can quietly fail to install. Packages like fsevents are optional on non-Mac platforms; npm records a different lockfile shape per platform unless you check in a single canonical lockfile and keep environments consistent.
Range mismatches in transitive deps cause duplicates. A small range incompatibility deep in the tree multiplies copies of a shared library. Tools like npm dedupe try to flatten, but only when ranges overlap.
overrides lets you force a specific version. Useful when a transitive dependency has a vulnerability and the parent has not bumped. Use sparingly; you are taking responsibility for compatibility.
Why this matters for security
Knowing how the resolver actually works changes how you read a package-lock.json diff. A change in your direct dependencies often produces a much larger diff in the lock — every transitive that re-resolves to a new version shows up.
For supply-chain review, the question is not "what version did I declare?" but "what version did the lockfile actually pick?" The lockfile is the source of truth for what your build will fetch. Reading the diff before merging a dependency update is the cheapest defense against silent transitive surprises.