"All three resolve the same registry. The disagreement is about where the bytes go on disk."
npm — The Flat Tree
npm 3+ hoists dependencies into a single flat node_modules/ directory. If package A and package B both depend on lodash@4.17, you get one copy of lodash at the top level. If A wants lodash@4.17 but B wants lodash@3.10, npm hoists one of them to the top and nests the other deeper. The advantage: small footprint, simple resolution algorithm Node already understood (walk up the directory tree).
The disadvantage: phantom dependencies. Your code can require('underscore') even if your package.json doesn't list underscore — because some transitive dep brought it in and hoisted it. Works on your machine, breaks the moment that transitive dep updates and stops pulling underscore. Real bugs, hard to catch.
pnpm — Content-Addressable Store + Symlinks
pnpm keeps a single content-addressable store at ~/.local/share/pnpm/store (or similar). Every version of every package on your machine, ever, is stored once. node_modules in your project is just symlinks (or hardlinks) into that store. Disk usage drops by an order of magnitude for anyone working on multiple Node projects.
Critically, pnpm by default uses strict isolation: your node_modules/ only contains the packages your package.json declares. Transitive deps live in node_modules/.pnpm/ in a flat layout that Node's resolver can find but your code can't directly import. Result: phantom dependencies become impossible. If you didn't list it, you can't require it.
This is why most monorepo tooling (Turborepo, Nx, modern Next.js) recommends pnpm. The strictness is a feature.
Bun — JS Runtime With Bundled Package Manager
Bun is a full alternative JavaScript runtime, but its bun install is a Node-compatible package manager. It writes a node_modules tree that Node can use. The selling point is speed — Bun's installer is written in Zig and is often 5-20x faster than npm.
You can use bun install with node script.js (Bun as installer, Node as runtime). You can also run scripts with bun script.js (Bun as both). The dependency tree Bun writes is npm-compatible, so this works.
Lockfiles — The Ground Truth
- npm →
package-lock.json - pnpm →
pnpm-lock.yaml - yarn →
yarn.lock - bun →
bun.lockb(binary)
npm install respects ranges and updates the lockfile; npm ci respects the lockfile and errors if it mismatches package.json. Use ci in CI.Switching Managers — Don't Mix
Within a single project, pick one and stick with it. Mixing npm install and pnpm install in the same repo creates conflicting lockfiles, mismatched node_modules layouts, and unreproducible installs. Use packageManager in package.json plus Corepack to enforce: "packageManager": "pnpm@9.10.0" tells everyone (and CI) which manager and version is canonical.
Pippa's Confession
du -sh node_modules with npm (2GB) vs pnpm (180MB). The size difference is one measurement; the no-phantom-deps strictness is another. Now when I start a new project I default to pnpm without thinking. The exception is when a tool refuses pnpm (rare in 2026) — then I match the tool.