From npm to pnpm: How Package Management Evolved
npm gave JavaScript a shared package registry. Yarn improved reliability. pnpm rethought the storage model entirely. Understanding each architectural decision explains why the ecosystem kept moving.
npm gave JavaScript a shared registry and a way to install packages with a single command. That was enough for a long time. But as projects grew larger and teams grew bigger, the architecture underneath that simple command started to show real structural problems. Yarn addressed some of them. pnpm addressed the rest. Each tool represents a distinct architectural decision, not just a faster version of what came before.
npm and the node_modules Problem
When npm installs a package, it downloads it and writes it into a node_modules folder inside your project. Every project has its own node_modules. If you have ten projects that all depend on the same version of lodash, npm writes ten separate copies of lodash to disk, one per project.
In its early versions, npm installed dependencies in a nested structure. If package A depended on package B, npm would install B inside A’s own node_modules folder. This was logically correct but created deeply nested directory trees that caused issues on certain operating systems and made deduplication nearly impossible.
npm later switched to a flat node_modules structure, where all packages, direct and transitive, are hoisted to the root node_modules directory. This reduced nesting but introduced a different problem: phantom dependencies.
A phantom dependency is a package that your code imports but that you never declared in your package.json. Because hoisting places transitive dependencies at the root, your code can reach them as if they were your own. The code works, but the dependency is undeclared. If the package that transitively pulled in that phantom dependency is removed or updates its own dependencies, your code breaks without any change on your part.
npm introduced package-lock.json in 2017 to address a separate problem: reproducibility. Without a lockfile, two developers running npm install at different times could receive different patch versions of the same package. The lockfile records the exact resolved version of every package in the tree, so installs are reproducible across machines.
npx and Temporary Execution
npx ships with npm and solves a narrower problem. Before it existed, running a CLI tool that was not installed globally required either installing it globally or adding it to the project’s devDependencies. npx allows you to execute a package binary without installing it permanently. It fetches the package, runs it, and discards it. This is architecturally distinct from npm install in that no writes to node_modules happen for normal use. The focus is execution rather than installation.
Yarn and the Determinism Problem
Yarn was introduced as a response to npm’s early lack of determinism and its slow, sequential installation process. Yarn introduced a lockfile, yarn.lock, before npm had package-lock.json. Beyond the lockfile, Yarn fetched packages in parallel and maintained a local cache so that packages already downloaded would not be re-fetched on subsequent installs.
The node_modules structure Yarn Classic produced was still flat, still hoisted, and still subject to phantom dependencies. The improvements were real but the underlying model was the same.
Yarn Berry, released as Yarn 2, took a more significant architectural step with Plug’n’Play (PnP). Instead of writing packages into node_modules, PnP stores packages as zip archives in a .yarn/cache directory and generates a single .pnp.cjs file that maps every package and version to its location in that cache. Node.js is patched at startup to resolve modules through this map instead of traversing the filesystem. node_modules is eliminated entirely. The tradeoff is that tooling which assumes node_modules exists, editors, bundlers, and native addons, requires additional configuration to work correctly.
pnpm and the Content-Addressable Store
pnpm takes a different approach to the disk duplication problem. Rather than writing packages into each project’s node_modules, pnpm maintains a single global content-addressable store on the machine, typically located at ~/.local/share/pnpm/store on Linux or an equivalent path on other operating systems. When a package is installed into a project, pnpm hard-links files from that global store into the project’s node_modules instead of copying them.
A hard link is not a copy. It is a second directory entry pointing to the same data on disk. If you have one hundred projects that all use the same version of a package, pnpm stores the files once in the global store and creates hard links from each project to those same files. The disk cost is paid once regardless of how many projects reference that package. When a new version of a package differs from a previous one, only the changed files are added to the store, not the entire package again.
pnpm also solves the phantom dependency problem directly. It does not hoist all packages to the root of node_modules. Instead, it creates a .pnpm virtual store directory inside node_modules that contains the full dependency tree, and only places symlinks to a project’s direct dependencies at the root level. Code can only import what is declared in package.json. Attempting to import a transitive dependency that was not declared will fail, which is the correct behaviour.
This structure makes pnpm’s node_modules layout stricter than npm’s or Yarn Classic’s, but also more predictable. The dependency graph your code can access matches exactly what you declared.
What Changed and What Did Not
npm established the model: a registry, a manifest, a local folder. Every tool after it kept the registry and the manifest. What changed was the storage model and the resolution strategy.
Yarn addressed npm’s early reliability problems with a lockfile and parallel installs, then later attempted to eliminate node_modules entirely with PnP. pnpm kept node_modules but made it strict and eliminated disk duplication through hard links and a shared store. These are not minor optimisations. They are different architectural positions on the same problem.
What to Check Next
The practical difference between these three tools in terms of install speed, disk usage, and security vulnerability scanning is measurable. If you want to observe it directly, install the same project three times, once with npm, once with Yarn, and once with pnpm, then compare the results:
# npm
time npm install
# yarn
time yarn install
# pnpm
time pnpm install
Use du -sh node_modules after each install to compare disk usage. Run npm audit, yarn npm audit, or pnpm audit to see how each tool surfaces dependency vulnerabilities. The numbers will tell you more than any summary can.