All three install packages from the npm registry. They differ in how they install — disk layout, speed, strictness, and the lockfile format. Picking one matters more than people think.
The big idea behind each
- npm — the original. Ships with Node. Uses a hoisted, flat
node_modules. Lockfile:package-lock.json. - yarn — Facebook’s reaction to slow npm. Classic v1 uses hoisted layout like npm. Modern Yarn (Berry, v2+) uses Plug’n’Play (PnP) — no
node_modulesat all. Lockfile:yarn.lock. - pnpm — performant npm. Uses a global content-addressable store + hardlinks + a nested-but-symlinked
node_modules. Lockfile:pnpm-lock.yaml.
The install layout difference
This is the key bit. Same package.json → very different folders.
node_modules/
├── express/
├── lodash/ ← hoisted up
├── debug/ ← hoisted up
└── pg/
└── node_modules/
└── pg-types/
~/.pnpm-store/ (global, hashed files)
node_modules/
├── express → .pnpm/express@4.18/...
├── pg → .pnpm/pg@8.11/...
└── .pnpm/
├── express@4.18.0/node_modules/express/
└── lodash@4.17.21/node_modules/lodash/
Why pnpm is so much faster (and uses less disk)
pnpm keeps one copy of each package version in a global store (~/.pnpm-store). When we install, it creates hardlinks from node_modules to that store. Hardlinks share the same disk blocks — basically zero copy.
So 50 projects all using react@18.2.0 share one copy on disk. With npm, each project has its own full copy. On a dev laptop, this saves tens of GB.
# Install in current project
pnpm install
# See the store size
pnpm store path
# /Users/me/Library/pnpm/store/v3
Strictness — phantom dependencies
This is where pnpm beats npm in code correctness. With npm’s flat layout, our code can require("debug") even if we never listed debug in our package.json — because some transitive dependency installed it and hoisting flattened it to the top.
// works with npm if any dep depends on lodash, even if WE don't:
const _ = require("lodash"); // phantom dep!
The day that transitive package upgrades and drops lodash, our code breaks. pnpm’s symlink-based layout makes this impossible — we can only require what we explicitly declared.
Lockfiles — three formats, same purpose
A lockfile records the exact version of every package (direct and transitive) so we get the same install on every machine, every time.
# npm
package-lock.json # JSON, very verbose
# yarn
yarn.lock # custom format, more compact
# pnpm
pnpm-lock.yaml # YAML
Always commit the lockfile. Without it, CI may install different transitive versions than dev — leading to “works on my machine” bugs.
For CI we use the strict install variants which fail if lockfile and package.json disagree:
npm ci # strict install, deletes node_modules first
yarn install --immutable
pnpm install --frozen-lockfile
Common commands side by side
Workspaces / monorepos
All three support workspaces — multiple packages in one repo.
// package.json at root
{
"workspaces": ["packages/*", "apps/*"]
}
pnpm uses pnpm-workspace.yaml instead:
packages:
- 'packages/*'
- 'apps/*'
For monorepos, pnpm is the most popular choice in 2026 because of strictness and speed. Yarn Berry workspaces are powerful but the PnP layout breaks some tools.
Which one should we use?
- pnpm — best default for new projects. Fast, disk-efficient, strict. The whole frontend ecosystem (Vue, Vite, Astro) uses it.
- npm — fine for small projects. Pre-installed everywhere. Zero setup.
- yarn — still solid for legacy projects on Yarn 1. Yarn Berry’s PnP is interesting but the migration is real work.
Whichever we pick, stick with one per project and commit the lockfile.