· pnpm / npm / package-manager
pnpm vs npm — what actually changes when you switch
Switch if you run a monorepo or care about install speed and disk. Stay on npm if you have phantom-dep debt you cannot audit. Here is the concrete difference.
By Ethan
1,543 words · 8 min read
Switch to pnpm if you run a monorepo or care about CI install speed. Stay on npm if you have phantom dependencies you can’t audit and no time to fix them. That’s the verdict — the rest of this article is what helps you figure out which side of that line you’re on.
Who this is for
JavaScript and TypeScript engineers who use npm today and are wondering if the switch is worth the friction. Monorepo teams get the most out of pnpm. Solo devs on a single package will still see speed gains, but the disk and workspace advantages are where pnpm really separates.
What we tested
Benchmark numbers are from pnpm.io/benchmarks, last updated 2026-05-14. These are pnpm’s own benchmarks, not a third-party lab — take the raw multipliers as directional rather than gospel. The test scenario is a large monorepo (“lots of files”), which is the workload that matters most if you’re evaluating a switch.
Speed
| Scenario | npm | pnpm | Ratio |
|---|---|---|---|
| Cold install (no cache, no lockfile, no node_modules) | 29.1s | 7.8s | 3.7× faster |
| With cache + lockfile | 1.3s | 525ms | 2.5× faster |
| With cache + lockfile + existing node_modules | 9.4s | 2.3s | 4.1× faster |
| Update after lockfile change | 6.6s | 3.9s | 1.7× faster |
The gap is biggest on cold installs — the case you hit on a fresh CI container or a new developer machine. The warm-cache scenario (cache + lockfile, no node_modules) is the common CI case: pnpm is still 2.5× faster there.
One counterintuitive finding: caching the pnpm store in CI is not always worth the overhead. pnpm’s 7.8s cold install is fast enough that a slow cache restore can cost more than it saves. Benchmark your restore time before adding store caching for its own sake.
Disk usage
pnpm stores every package version once in a global content-addressable store (~/.pnpm-store on Linux/Mac, %LOCALAPPDATA%\pnpm\store on Windows), then hard-links into each project’s node_modules.
The concrete difference: if [email protected] has 100 files and [email protected] changes 1 of them, pnpm adds 1 file to the global store. npm writes 101 fresh files — a full copy of the new version.
In a 20-package monorepo where every package depends on React 18.3.1, npm installs React 20 times (once per package). pnpm installs it once, hard-links the rest. There’s no single “X% savings” stat — it scales with how many packages share a version — but in a dense monorepo it compounds fast.
Phantom dependencies
This is the part where migrations break.
npm (and Yarn 1.x) flatten node_modules. Any package installed by a dependency lands in the shared root directory and is accessible from your code, whether you declared it or not.
Classic example: your project depends on express. Express depends on debug. With npm, require('debug') works in your code because debug was hoisted into the shared flat tree. It’s never in your package.json. If Express ever drops debug, your code silently breaks.
pnpm isolates each package to its own node_modules, containing only what that package declared. require('debug') fails if debug isn’t in your package.json.
Where migrations typically break:
- Webpack/Babel toolchains — commonly load plugins by traversing a flat tree
- Jest configurations — transform and
moduleNameMappersettings that assume flat resolution postinstallscripts — callrequire()on transitive deps of sibling packages
Real documented case: webpack-dashboard uses babel-traverse without declaring it. Works on npm, fails on pnpm because babel-traverse isn’t in webpack-dashboard’s own declared deps.
Audit before you migrate. Run npx depcheck or npx knip on your project. These tools surface undeclared dependencies before you’re debugging runtime failures after the switch.
Escape hatches, in order of preference:
- Fix the code — add missing deps to
package.json(correct approach) .pnpmfile.mjs— inject missing deps at resolution time without touchingpackage.jsonfiles you don’t ownpublic-hoist-pattern— hoist specific patterns to rootnode_modules(e.g.,['@babel/*', '*plugin*'])shamefully-hoist = true— hoists everything, same flat layout as npm; defeats isolation but lets you migrate incrementallynodeLinker: hoisted— full npm-style layout without symlinks; no phantom dep errors but also no disk or speed advantages
Use #1 wherever possible. #4 and #5 are migration scaffolding, not a destination.
Workspace support
pnpm uses a pnpm-workspace.yaml at the repo root. npm uses a workspaces field in package.json. Functionally similar — with one important difference.
The workspace: protocol is pnpm’s standout feature for monorepos:
{
"dependencies": {
"@myapp/ui": "workspace:*"
}
}
This resolves only to the local package. It never silently falls back to the registry if the version doesn’t match. npm workspaces have no equivalent enforcement. In a large monorepo with many inter-package references, this catches the class of bug where a stale local package quietly resolves from the registry.
When publishing, pnpm automatically rewrites workspace:^1.0.0 to the real semver range in the published package.json. No manual step.
A single pnpm-lock.yaml at the workspace root covers all packages by default.
The framework adoption signal is strong: Next.js, Vue, Vite, Nuxt, Astro, and Material UI all use pnpm workspaces internally.
Turborepo
Turborepo supports pnpm natively. The only pnpm-specific difference is the install flag when bootstrapping:
pnpm add turbo --save-dev --ignore-workspace-root-check
Task pipelines, caching, and remote cache work identically across package managers. This is a cosmetic difference, not a compatibility concern.
Lockfile and CI
pnpm-lock.yaml pins exact versions the same way package-lock.json does. pnpm auto-enables frozen-lockfile mode when CI=true is detected — equivalent to npm ci. You don’t need to remember the flag.
One behavior to know before you commit to pnpm v11: it fails if the lockfile was generated by a newer major pnpm version. Your CI pnpm version and your local pnpm version need to stay in sync. The fix is to pin it:
{
"packageManager": "[email protected]"
}
Recommended GitHub Actions setup:
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
pnpm has documented CI setup for GitHub Actions, GitLab CI, CircleCI, Azure Pipelines, Bitbucket Pipelines, Jenkins, Semaphore, and Travis CI.
Migrating from npm to pnpm
Nine steps, in order:
1. Install pnpm:
corepack enable && corepack prepare pnpm@latest --activate
2. If it’s a monorepo, create pnpm-workspace.yaml at the root:
packages:
- 'packages/*'
- 'apps/*'
3. Convert the lockfile:
pnpm import
Reads package-lock.json, generates pnpm-lock.yaml.
4. Delete old artifacts:
rm -rf node_modules package-lock.json
# For monorepos: also delete node_modules in each workspace package
5. Install:
pnpm install
6. Fix phantom deps. If installs succeed but runtime errors appear, run npx depcheck, add the flagged packages to package.json, reinstall.
7. Pin the version:
{
"packageManager": "[email protected]"
}
8. Update CI (see the GitHub Actions snippet above).
9. Update Dockerfiles:
# Replace
RUN npm ci
# With
RUN pnpm install --frozen-lockfile
One rollback note: keep package-lock.json in version control until you’re fully confident. pnpm and npm cannot both manage the same node_modules. When you’re committed, delete package-lock.json.
Hosting
Vercel and Railway both detect pnpm automatically via the packageManager field in package.json. Vercel reads it at build time and sets the install command without any config change. Render also supports pnpm, though it lacks a tracked affiliate link in our table at time of writing.
Verdict
Switch to pnpm if:
- You manage a monorepo — the
workspace:protocol and single shared lockfile alone are worth it - You’re burning CI time on npm installs and want a structural fix rather than more caching
- You’re running multiple projects on one machine and hitting disk limits from duplicated
node_modules
Stay on npm if:
- You have a large legacy codebase with phantom dependencies you can’t audit right now
- Your project is a single package, your team is small, and install speed isn’t a bottleneck
The migration is reversible — pnpm import converts npm lockfiles, and you can reconstruct a package-lock.json by running npm install after removing pnpm. Run npx depcheck first, fix what it finds, and the rest of the migration is mechanical.
If you’re also evaluating JavaScript runtimes, Bun vs Node.js is the next comparison to read — Bun ships its own package manager and lockfile format, which makes this pnpm vs npm question moot if you switch. If you’re setting up a monorepo build pipeline, Vite vs Webpack covers the bundler decision that usually follows the package manager choice.
Caveats
Benchmarks are from pnpm’s own benchmark page, not an independent lab. The relative ordering (pnpm faster, more disk-efficient) is well-established. The exact multipliers should be treated as approximate, especially if your workload differs significantly from a large multi-package monorepo.
shamefully-hoist and nodeLinker: hoisted exist as escape hatches, but they eliminate most of the reasons to switch. If you need them permanently rather than transitionally, reconsider whether the migration cost is justified for your project.