If you write JavaScript or TypeScript professionally, you’ve used npm. It ships with Node.js, it’s everywhere, and for years it was the only game in town. But after running npm on real-world projects — enterprise monorepos, Next.js apps, Angular dashboards, CI pipelines that bill by the minute — I can tell you: npm is holding you back.
I switched to pnpm about two years ago. I haven’t looked back. Here’s what actually changed.
The Problem with npm (That Nobody Talks About)#
npm works. That’s never been the issue. The issue is how it works under the hood — and the problems that creates once your projects grow beyond a weekend hack.
Flat node_modules Is a Lie#
npm hoists every dependency into a single flat node_modules folder. Sounds convenient, right? Here’s the catch: your code can import packages you never declared in package.json. This is called phantom dependencies.
It works on your machine because npm happened to hoist that package. It works in your teammate’s environment because they have the same lock file. Then it breaks in production, or in a Docker build, or on a new developer’s machine — and nobody understands why.
This isn’t a hypothetical. I’ve debugged this exact issue in production. Twice.
Your Disk Is Full of Duplicates#
Have five Next.js projects on your machine? npm keeps five separate copies of React, five copies of TypeScript, five copies of every shared dependency. We’re talking hundreds of megabytes duplicated per project.
On a 256GB MacBook, that adds up fast.
CI Pipelines Are Slow (And You’re Paying For It)#
npm resolves and installs dependencies sequentially. On a CI runner with a cold cache, a large project can spend 30+ seconds just on npm install. Multiply that by every PR, every merge, every deployment. Those minutes turn into hours per month — and if you’re on a metered CI provider, hours turn into dollars.
What pnpm Does Differently#
pnpm isn’t a wrapper around npm. It’s a fundamentally different approach to dependency management. Three things set it apart.
1. Content-Addressable Storage#
When pnpm installs a package, it stores the files in a global content-addressable store on your disk — typically at ~/.local/share/pnpm/store. Every project that needs that package gets hard links to the same files. Not copies. Links.
What this means in practice:
- One copy of React on your entire machine, no matter how many projects use it.
- If a new version changes 3 files out of 300, pnpm only downloads and stores those 3 new files.
- Disk savings of 50–70% compared to npm are common. On a machine with many projects, I’ve seen savings over 80%.
2. Non-Flat node_modules (Finally, Correctness)#
pnpm creates a symlinked node_modules structure where only your declared dependencies are accessible at the top level. Transitive dependencies are nested properly using symlinks to the .pnpm/ directory.
This means:
- No phantom dependencies. If you didn’t declare it in
package.json, you can’t import it. Period. - Your code is honest about its dependencies. What works locally will work in CI, in Docker, in production.
- Bugs that would have slipped through with npm get caught at development time, not deployment time.
3. Parallel, Cached Installs#
pnpm resolves, fetches, and links dependencies in a streaming pipeline — all three stages overlap. npm does them sequentially.
The official pnpm benchmarks:
| Scenario | npm | pnpm |
|---|---|---|
| Clean install (no cache, no lockfile) | 31.3s | 7.6s |
| With cache + lockfile + node_modules | 1.2s | 546ms |
| With cache + lockfile (CI scenario) | 7.5s | 2.0s |
| With cache only | 12.1s | 5.4s |
That’s 2–4x faster across the board. On a monorepo with 10+ packages, the gap widens significantly.
Monorepo Support That Actually Works#
npm added workspace support a few years ago, and it’s fine. It installs dependencies across packages. That’s about it.
pnpm workspaces are in a different league:
pnpm --filterlets you run commands on specific packages, their dependencies, or their dependents. You can target by name, by directory, by changed files — it’s surgical.workspace:protocol explicitly declares local package dependencies. No ambiguity about whether you’re pulling from the registry or the local workspace.- Shared lockfile means one
pnpm-lock.yamlat the root, with every dependency deduplicated across the entire monorepo. - Catalogs (introduced in pnpm 9.5) let you define shared version constraints across all workspace packages in one place.
Catalogs: One Version to Rule Them All#
Before catalogs, updating a shared dependency across a monorepo meant touching every package.json. With catalogs, you define versions once in pnpm-workspace.yaml:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^18.3.1
typescript: ^5.4.0
zod: ^3.23.0Then reference them in any workspace package:
{
"dependencies": {
"react": "catalog:",
"zod": "catalog:"
}
}One version update in one file. Done. You can also define named catalogs for managing compatibility matrices across different environments.
This isn’t theoretical. Look at who uses pnpm workspaces in production: Next.js, Vue, Nuxt, Vite, Turborepo, Prisma, Material UI, Astro, SvelteKit, Vitest, n8n, Qwik — the list keeps going. These are not small projects.
pnpm 10: Security by Default#
pnpm 10 (released December 2025) made security the default rather than an opt-in. Two features worth knowing:
Script blocking by default: pnpm 10 prompts before running lifecycle scripts (postinstall, preinstall, etc.) from untrusted packages. Supply chain attacks via malicious install scripts are a real vector — this adds friction in the right place.
minimumReleaseAge: You can configure pnpm to hold packages published less than 24 hours ago. This catches the window where attackers publish a malicious package and immediately target projects updating their dependencies.
# .npmrc
minimum-release-age=24hnpm has no equivalent defaults for either of these.
The Migration Takes Five Minutes#
Switching from npm to pnpm is painless:
# Install pnpm globally
npm install -g pnpm
# Remove npm artifacts
rm -rf node_modules package-lock.json
# Install with pnpm
pnpm installThat’s it. Your package.json doesn’t change. Your code doesn’t change. pnpm reads the same package.json format, runs the same lifecycle scripts, and generates its own pnpm-lock.yaml.
For a monorepo, add a pnpm-workspace.yaml at the root:
packages:
- "apps/*"
- "packages/*"Locking the pnpm Version for Your Team#
Add a packageManager field to your package.json so everyone on the team uses the same pnpm version:
{
"packageManager": "pnpm@10.32.0"
}Corepack (bundled with Node.js 14.19–24) enforces this automatically. Note: Corepack is being removed from Node.js 25+, so for new projects targeting Node 25 onward, use pnpm’s standalone installer and manage versions directly.
Extra Features Worth Knowing#
pnpm patch — Patch a dependency inline without forking the repo. The patch is stored in your project as a .patch file and reapplied on every install. No more maintaining a fork for a two-line fix.
pnpm env — Install and switch Node.js versions without a separate tool. Useful in CI and Docker where you want everything managed by a single binary. For interactive local development where you switch between projects with different Node versions, you still want nvm or fnm for automatic switching via .nvmrc.
Side-effects cache — If a package runs a postinstall build script (native compilation, etc.), pnpm caches the output. Reinstalls skip the build step entirely.
pnpm clean (pnpm 10) — Safely remove node_modules across all workspace packages in one command.
The One Caveat (And Its Fix)#
Some older packages or tools assume npm’s flat node_modules layout. They reach into transitive dependencies they never declared. With pnpm’s strict structure, those imports fail.
In practice, this is rare in 2026 — most of the ecosystem has adapted. But if you hit it, there’s a quick workaround. Add this to your .npmrc:
shamefully-hoist=trueThis tells pnpm to hoist dependencies like npm does. It defeats some of the strictness benefit, so treat it as a temporary fix while you identify the offending package.
A better approach: use public-hoist-pattern to selectively hoist only what’s needed:
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*Side-by-Side Comparison#
| Feature | npm | pnpm |
|---|---|---|
| Storage model | Copies per project | Content-addressable store + hard links |
| Disk usage | Duplicates everywhere | 50–70% savings typical |
| Install speed | Sequential resolution | Parallel streaming pipeline (2–4x faster) |
| node_modules | Flat (hoisting) | Strict symlinked structure |
| Phantom dependencies | Silently allowed | Blocked by default |
| Monorepo support | Basic workspaces | First-class: filter, catalogs, workspace protocol |
| Lock file | package-lock.json | pnpm-lock.yaml (more deterministic) |
| Security defaults | None | Script blocking, release age hold (pnpm 10) |
| Patching dependencies | Not supported | Built-in pnpm patch |
| Node.js version management | Not supported | pnpm env (good for CI; use nvm/fnm for interactive dev) |
| Side-effects cache | No | Yes |
When to Stick with npm#
Being fair: npm is still the right call in a few scenarios.
- Tutorials and getting started — npm ships with Node, so new developers don’t need to install anything extra. For learning, the zero-setup matters.
- Single small project, no monorepo — If you have one small app and disk space isn’t a concern, the benefits are less noticeable.
- Team can’t adopt new tools — If your organization has strict tooling policies and adding pnpm to the approved list is a six-month process, just use npm.
For everything else — especially enterprise projects, monorepos, CI-heavy workflows, and teams that care about correctness — pnpm is the better default.
Bottom Line#
npm is the Toyota Corolla of package managers. Reliable, available everywhere, gets the job done. pnpm is the same vehicle with a better engine, better fuel economy, and seatbelts that actually work.
The major JavaScript frameworks have already made their choice. Next.js, Vue, Vite, Turborepo, Astro, SvelteKit — they all use pnpm. That’s not a coincidence.
Try it on your next project. The migration is five minutes, the benefits are immediate, and the worst case is pnpm install works exactly like npm install did — only faster.
npm install -g pnpmThat’s your starting line.
