Every JavaScript package manager - npm, pnpm, Yarn - is solving the same underlying problem: turning a declared set of dependency ranges into a concrete, reproducible tree of installed code.
Package Managers Basics covers the practical choice between them for a team; this page is about the model that sits beneath all three - the registry, semantic versioning, dependency-graph resolution, and the lockfile as the actual source of truth.
A package manager resolves a graph of dependency version ranges into one concrete, reproducible set of installed versions, then decides where each package physically lives on disk.
Insight: Most "it works on my machine" and "CI installed something different" bugs trace back to a gap between what package.json allows and what actually got installed - the lockfile exists specifically to close that gap.
When to Use This Model: Debugging why two machines installed different dependency versions, choosing between npm, pnpm, and Yarn for a team, understanding phantom dependencies, and reasoning about supply-chain risk before adopting a package.
Limitations/Trade-offs: Semver ranges express intent, not a guarantee - a publisher can still ship a breaking change inside a "compatible" release, and no lockfile protects against a dependency that was malicious or compromised from the start.
Related Topics: module resolution, workspaces, npm publishing and provenance, supply-chain auditing.
A package in the Node ecosystem is a folder containing a package.json that declares a name, a version, its own dependencies, and its entry points - published to a registry (npmjs.org's registry by default, though private registries and scoped org registries exist too) or resolved locally through a workspace, a file: path, or a Git URL.
Installing a package is fundamentally an HTTP negotiation with that registry: fetching metadata about available versions, then downloading a tarball for the one that gets selected - not some opaque build process.
Semantic versioning gives that negotiation a shared vocabulary: a version number major.minor.patch where, by convention, a patch bump means bug fixes only, a minor bump means new backward-compatible features, and a major bump means breaking changes.
A package.json dependency range like ^5.2.0 expresses acceptable versions under that convention - "any 5.x.x at or above 5.2.0" - not a specific version, and definitely not a guarantee that every publisher follows semver correctly.
A useful analogy: the registry is a library's card catalog, semver ranges are a request like "any edition published after 2020 is fine," and the lockfile is the specific copy you actually checked out.
Returning to the library and asking for "any edition after 2020" again might hand you a different book than last time - but showing your checkout receipt (the lockfile) gets you the exact same copy, every time.
Resolution works by building a dependency graph: starting from the root package.json, the manager reads each declared range, fetches metadata for that package, and recurses into that package's own dependencies - which may themselves overlap or conflict with what other parts of the graph need.
The manager's job is to find one consistent set of concrete versions that satisfies every range in that graph simultaneously, then decide where on disk each resolved package actually lives.
That "where on disk" question is where npm, pnpm, and Yarn genuinely diverge, and it's worth understanding as three different strategies for the same resolved graph rather than three different resolution algorithms:
Flat, hoisted node_modules (npm, Yarn Classic): shared dependencies get lifted as high in the tree as version conflicts allow, so most packages end up in one top-level node_modules folder. This is simple and compatible with tools expecting a traditional layout, but it lets code accidentally require() a package it never declared - a phantom dependency - simply because hoisting happened to place it within reach.
Content-addressable store with strict symlinks (pnpm): every package version is stored once globally on disk, and each project gets a node_modules built from symlinks into that store, structured so a package can only resolve what it actually declared. This closes the phantom-dependency gap by construction, at the cost of a stricter layout some legacy tooling doesn't expect.
Plug'n'Play (Yarn Berry): skips node_modules entirely, resolving imports through a generated .pnp.cjs map instead of filesystem traversal - the fastest resolution model, but it requires tooling (bundlers, test runners) to understand PnP or run through a compatibility layer.
The lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock) is what makes any of this reproducible: it records the exact resolved graph - every version, every integrity hash - that the ranges in package.json produced on a specific install.
That separation matters: package.json states what's acceptable, and the lockfile states what was actually chosen, which is why npm ci (or the equivalent --frozen-lockfile flag in pnpm/Yarn) installs strictly from the lockfile and fails outright if it doesn't match package.json, rather than re-running range resolution the way a plain npm install does.
The registry's open publishing model is also the root of the ecosystem's supply-chain risk: anyone can publish a package, integrity hashes and lockfiles protect against a package being silently tampered with after the fact, but neither protects against a package that was malicious, compromised, or simply unmaintained from the moment it was published.
Supply Chain: npm audit & Socket covers the tooling built specifically to gate that risk before it reaches a merge.
Workspaces layer directly on top of this same resolution model rather than replacing it: a monorepo's internal packages resolve as workspace:* specifiers, which each manager treats as "link the local copy instead of fetching from the registry" - the dependency graph still gets built and locked the same way, just with some nodes pointing at local folders instead of tarballs.
Workspaces & Monorepos covers that in detail; the point worth keeping here is that workspaces are a resolution-source change, not a different package manager mode.
Trust has also evolved past "the lockfile matches, therefore it's fine": modern npm supports provenance attestation, a cryptographically verifiable link between a published package and the CI build that produced it, giving consumers evidence about how a package was built, not just what its hash is.
Publishing to npm covers generating that provenance for packages your own team ships.
Layout Strategy
Strength
Weakness
Best Fit
Flat/hoisted (npm, Yarn Classic)
Broad tool compatibility, simplest mental model
Permits phantom dependencies
Single-service repos, default choice
Content-addressable + symlinks (pnpm)
Disk-efficient across many projects, no phantom deps
Stricter layout can surface old bad assumptions
Monorepos, disk/CI-cache-sensitive teams
Plug'n'Play (Yarn Berry)
Fastest resolution, no node_modules at all
Requires PnP-aware tooling or a compatibility shim
"package.json is the source of truth for what's installed." It only declares acceptable ranges - the lockfile records what was actually resolved and installed, and that's what a reproducible install trusts.
"npm install and npm ci do the same thing."npm install can still re-resolve ranges and update the lockfile; npm ci deletes node_modules and installs strictly from the existing lockfile, failing if it's out of sync with package.json.
"A caret range like ^1.2.3 can never introduce a breaking change." It can, whenever a publisher doesn't follow semver correctly - the range expresses an expectation, not an enforced contract.
"All package managers produce the same node_modules layout for the same dependencies." They don't - hoisting strategy (flat, strict-symlinked, or none at all with PnP) is exactly where npm, pnpm, and Yarn diverge even when they resolve to identical versions.
"A phantom dependency that works locally is harmless." It works only because hoisting happened to place that package within reach - it's undeclared, unguaranteed, and can silently break the moment the dependency tree shifts or the team switches to a stricter manager.
What is a package manager actually resolving when I run install?
It's resolving a dependency graph: reading every declared version range starting from the root package.json, recursing into each dependency's own dependencies, and finding one consistent set of concrete versions that satisfies all of them.
What's the practical difference between `package.json` and the lockfile?
package.json declares acceptable ranges (what versions you're willing to accept); the lockfile records the exact versions and integrity hashes that were actually resolved and installed the last time someone ran install.
Why does `npm ci` exist if `npm install` already works?
npm ci skips range resolution entirely and installs strictly from the lockfile, failing fast if it's out of sync with package.json - that strictness is what makes it the right choice for CI, where reproducibility matters more than convenience.
What is a "phantom dependency"?
A package your code imports successfully without declaring it in package.json, purely because a flat/hoisted node_modules layout happened to place it within reach - it's not guaranteed to keep working if the dependency tree changes.
How does pnpm avoid phantom dependencies?
Its content-addressable store links packages into each project via strict symlinks that only expose what a package actually declared, instead of hoisting everything into one shared, broadly-accessible node_modules.
What does Yarn Berry's Plug'n'Play change about resolution?
It replaces node_modules filesystem traversal with a generated .pnp.cjs map that resolves imports directly, which is faster but requires tooling that understands PnP or runs through a compatibility layer.
Does semver actually guarantee compatibility?
No - it's a convention publishers are expected to follow, not something npm enforces, so a "compatible" minor or patch release can still ship a breaking change if the publisher made a mistake.
What does npm's provenance attestation add beyond the lockfile?
It cryptographically links a published package to the specific CI build that produced it, giving consumers evidence about how the package was built - the lockfile alone only proves what hash was installed, not how it was made.
How do workspaces fit into this resolution model?
They're a resolution-source change, not a different mode - internal packages resolve via workspace:* specifiers that point the manager at a local folder instead of a registry tarball, but the graph is still built and locked the same way.
Does a lockfile protect against a malicious package?
No - it protects against a package being silently tampered with after the fact, but it does nothing about a package that was malicious, compromised, or abandoned from the moment it was published, which is what dedicated supply-chain auditing tools address.
Why might two developers get different `node_modules` from the same `package.json`?
If there's no lockfile, or if one developer ran npm install and let ranges re-resolve against a newer registry state, they can land on different concrete versions - which is exactly the gap a committed lockfile and npm ci are meant to close.