npm, Yarn, or pnpm: The Disk and Speed Tradeoffs That Actually Matter
npm / yarn / pnpm: Stop Choosing Based on Gut Feeling
Use pnpm for new projects. Use pnpm workspaces for monorepos. Existing yarn classic projects don't need to be forcibly migrated. If you're still using npm to run CI, your build times and disk bills have room for improvement.
The core difference among the three lies in the dependency storage mechanism and performance. Let's look at a comparison chart:
Core Differences Explained
npm (Node Package Manager)
Node.js's official package manager, built-in with no extra configuration needed. Its biggest problem was early serial installation, which was slow; each project copies a full set of dependencies into node_modules, causing extremely high disk usage across multiple projects. npm v7+ introduced workspaces, and speed has improved noticeably since v7, but it still lags behind the other two.
yarn (Yet Another Resource Negotiator)
Released by Facebook in 2016, targeting npm's early pain points: parallel downloads for a significant speed boost, and yarn.lock for more reliable version locking. The current Yarn Berry (v2+) introduced PnP (Plug'n'Play) mode, completely abandoning node_modules, but it has many compatibility issues; many toolchains haven't fully adapted, and community acceptance is moderate.
pnpm (Performant npm)
Its biggest highlight is the global content-addressable store + hard links: all versions of packages are stored only once in ~/.pnpm-store, and each project references them via hard links, saving massive disk space. It also strictly isolates dependencies, preventing "phantom dependencies" (where code can access packages not declared in package.json), and performs best in monorepo scenarios.
Dependency Storage: Three Radically Different Philosophies
The core difference among package managers isn't the command-line API, but how dependencies are stored on disk. This decision directly determines installation speed, disk usage, and dependency isolation.
npm — Flattened node_modules
After npm v3, a flattening algorithm (hoisting) was introduced, promoting all dependencies as much as possible to the top-level node_modules, solving the problem of excessively deep nesting. The cost is "phantom dependencies": your code can require any package hoisted to the top level, even if it's not in your own package.json.
Each project stores a complete copy locally in node_modules. Five projects depending on the same version of React means five copies on disk.
yarn classic (v1) — Deterministic Hoisting + Parallel Downloads
yarn v1's biggest contribution was yarn.lock and parallel network requests. It fixed npm's early problem of "two installs on the same machine producing different results," but the dependency storage model is essentially the same as npm's: still flattened node_modules, and the phantom dependency problem still exists.
yarn v2/v3 (Berry) introduced PnP (Plug'n'Play) — completely abandoning node_modules and replacing it with a .pnp.cjs mapping file. Theoretically the fastest and strictest, but ecosystem compatibility remains a hard flaw: Webpack, Vite, and many CLI tools have uneven support for PnP, making the cost of troubleshooting high.
pnpm — Content-Addressable Storage + Hard Links
pnpm's mechanism is completely different. It maintains a global store directory (default ~/.local/share/pnpm/store), storing only one copy of each file for each version, using hard links to map them into each project's node_modules.
# Only one copy of [email protected] in the global store
~/.pnpm-store/v3/files/00/abc123... ← Real file
# Each project's node_modules is just a hard link
project-a/node_modules/react/index.js ← Hard link, same inode
project-b/node_modules/react/index.js ← Hard link, same inode
More critically, pnpm preserves a non-flat dependency structure: each package can only access its own declared dependencies, never accidentally accessing transitive dependencies, fundamentally eliminating the phantom dependency problem.
Performance Comparison: Let the Numbers Speak
Below is reference data from a medium-sized project (~200 direct dependencies) across three scenarios: fresh install, cached install with lockfile changes, and repeated install with no lockfile changes.
| Scenario | npm v10 | yarn v1 | pnpm v9 |
|---|---|---|---|
| Cold start (no cache) | ~85s ❌ | ~55s ⚠️ | ~40s ✅ |
| Cached, lockfile changed | ~40s ❌ | ~22s ⚠️ | ~14s ✅ |
| Cached, lockfile unchanged | ~18s ⚠️ | ~9s ⚠️ | ~3s ✅ |
| Disk usage (10 projects reusing same version deps) | 10× ❌ | 10× ❌ | ~1.1× ✅ |
pnpm's advantage in CI environments is even more pronounced: as long as the global store directory is mounted as a cache, incremental installs are extremely fast even if the runner is a new container.
# GitHub Actions example: caching pnpm store
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
Phantom Dependencies: An Underestimated Engineering Risk
Phantom dependencies are a byproduct of the npm/yarn flattening model. A typical scenario:
// package.json only declares webpack
{
"dependencies": {
"webpack": "^5.0.0"
}
}
// But the code directly requires webpack's indirect dependency
const lodash = require('lodash') // webpack depends on lodash, hoisted to top level
This code works now, but once webpack is upgraded, if the new version no longer depends on lodash, it will directly throw Cannot find module 'lodash' — and your package.json never declared it, making troubleshooting very subtle.
A more insidious situation:
You upgrade webpack, and lodash follows from 4.17.20 to 4.17.21. If some function's behavioral details changed between these two versions, your code might exhibit strange bugs without throwing any errors.
More critically, you would never investigate lodash — because you didn't touch it, it doesn't even appear in your package.json, and you might not even know your code is using it.
pnpm's strict mode structurally eliminates this problem — each package can only see dependencies declared in its own package.json. If you try to access an indirect dependency in a pnpm project, the install will directly error out, forcing you to explicitly write the dependency into package.json.
Monorepo: Why pnpm workspaces Are Better
All three support workspaces, but the implementation quality differs significantly.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
1. Shared dependencies are truly shared. If apps/web and apps/admin both depend on React 18, there's only one copy on disk, reused via hard links. npm/yarn workspaces also do hoisting, but each workspace still has its own copy.
2. More granular filtered execution. pnpm --filter supports filtering by package name, path, and dependency graph, running only packages affected by changes, drastically reducing CI time.
# Only build packages that depend on @myorg/ui (and @myorg/ui itself)
pnpm --filter "...[packages/ui]" build
# Only run all packages under apps/
pnpm --filter "./apps/*" dev
3. Cleaner protocol for linking local packages. Using the workspace:* protocol to reference local packages, pnpm automatically replaces it with the real version number upon publishing, requiring no manual maintenance or extra tools.
// apps/web/package.json
{
"dependencies": {
"@myorg/ui": "workspace:*" // Automatically replaced with "^1.2.0" on publish
}
}
Migration Cost: Switching from npm/yarn to pnpm
Migration is usually simpler than imagined. The main workload is in two areas:
1. Phantom Dependency Audit
This is the most common sticking point. After switching to pnpm, code that previously ran might directly error out due to phantom dependencies. It's recommended to use pnpm why <package> to investigate where a package is introduced from, then decide whether to explicitly declare it.
# Check why lodash is in node_modules
pnpm why lodash
# Example output
Legend: production dependency, optional only, dev only
[email protected]
dependencies:
webpack 5.88.0
└── lodash 4.17.21
2. Script and Toolchain Compatibility
Most npm scripts work directly. Note that npx should be replaced with pnpm dlx, and some build tools that directly manipulate node_modules paths (usually fixable with a config change).
Decision Guide
| npm | yarn v1 | pnpm | |
|---|---|---|---|
| Suitable for | Quick prototypes, script tools | Maintaining existing projects | First choice for new projects |
| Not suitable for | CI-sensitive, large monorepos | Not recommended for new projects starting from scratch | Extreme reliance on specific legacy toolchains |
| Keywords | Zero config, bundled with Node | No fuss, team familiarity | Monorepo, CI speedup, strict isolation |
As a frontend developer, consider the following:
- Personal projects, quick start → npm, zero config, works out of the box
- Team projects, performance-sensitive, Monorepo → pnpm, saves disk, fast, cleaner dependency relationships
- yarn → If the team already has existing projects using yarn classic (v1), continue using it; new projects are not recommended to start with Yarn Berry, as compatibility pitfalls are numerous
The current mainstream trend for new projects is pnpm. Well-known projects like Vite, Vue, and Nx are all using it.
Final Words
Package manager selection isn't technical purism; it's an engineering decision. pnpm's hard link model and strict isolation bring substantial benefits in most scenarios, which is why mainstream projects like Vue, Vite, and Turborepo are all using it.
But if your project is already running well and the team hasn't encountered obvious pain points, the benefits of migration need to be weighed against the cost. The biggest taboo in technology selection is "switching because it's new" or "not switching because it's old."