Why I Switched from npm to pnpm (And Why You Should Too)
A practical breakdown of why pnpm beats npm for modern JS/TS projects — faster installs, strict dependency resolution, and monorepo support.
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. 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 further.
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 where a package resolves 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 file for the whole monorepo
Before catalogs, updating a shared dependency across a monorepo meant touching every package.json. With catalogs, you define versions once in 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, and 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.
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 globallynpm install -g pnpm
# Remove npm artifactsrm -rf node_modules package-lock.json
# Install with pnpmpnpm 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 patches 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 installs and switches 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 removes node_modules across all workspace packages in one command.
The One Caveat (And Its Fix)
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 (enterprise projects, monorepos, CI-heavy workflows, 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.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.