pnpm vs npm: The Real Reason to Switch Isn't Speed
Most pnpm vs npm comparisons lead with install times. They're missing the point. npm's flat node_modules silently lets your code import packages you never declared, and the bug only surfaces in production. pnpm refuses to lie to you.
I lost an afternoon last year to a bug that didn't exist anywhere I could see. The build was green on my laptop. Tests passed in CI (continuous integration). Production threw Cannot find module 'lodash.merge' the first time real traffic hit a code path I rarely exercised, even though three files in the codebase imported lodash.merge and the tests had imported them. I'd never put lodash.merge in package.json. Somewhere in some install order, npm had hoisted it (pulled it up to the top of node_modules as a side effect of some other package wanting it), and my code had been quietly relying on it for months.
That's a phantom dependency. It's a package your code can import even though you never declared it. It works until it doesn't, and the moment it stops working is when some unrelated dep gets bumped and the chain that was carrying it disappears. That's usually right after deploy, when real traffic finally hits the path that needed it.
Here's the thing. The term “phantom dependency” isn't pnpm's. It was coined by Pete Gonzalez and the team behind Microsoft's Rush, the monorepo build tool, who've been documenting why flat node_modules is broken since 2017.[4] pnpm popularized the term in the rest of the JavaScript world, but the people who first wrote it down knew this was a bug a long time before pnpm did anything about it.
Summary
node_modules silently lets your code import packages you never declared. pnpm refuses. The bugs that surface during a switch were already in your code; npm was hiding them.Four package managers matter in 2026: npm (GitHub-owned, the default that ships with Node), pnpm(Zoltan Kochan's 2017 project, the one this whole article is about), Yarn 4 (Meta, mostly used by teams that set up a Yarn project years ago), and Bun (the runtime-plus-package-manager Anthropic now owns). npm dominates by inertia; pnpm pulled around three hundred million downloads in the last month, which is huge and still nowhere near npm.[11]
The case for pnpm in 2026 isn't that the daily install completes a few seconds faster. It's that npm puts packages in node_modulesthat you didn't ask for, and the version of node_modulesyou're shipping to production silently disagrees with the one you're running locally. pnpm doesn't fix this by being clever. It fixes it by refusing to lie to you.
What's actually different inside node_modules
Quick orientation for the cross-field reader: when you list a dependency in package.json, your package manager downloads it and all the things it depends on, recursively. Those deps of deps are called transitive dependencies. The whole graph lands in a folder called node_modules. The difference between npm and pnpm is the shape of that folder.
Here's the contrast at the directory level for a project that depends on Express:
# npm (flat / hoisted)
node_modules/
├── express/ ← your declared dep
├── accepts/ ← transitive, hoisted to top
├── body-parser/ ← transitive, hoisted to top
├── cookie/ ← transitive, hoisted to top
├── lodash.merge/ ← transitive, hoisted to top
└── ... (everything visible at top level)
# pnpm (symlinked / strict)
node_modules/
├── express -> .pnpm/express@4.18.2/node_modules/express
└── .pnpm/
├── express@4.18.2/node_modules/
│ ├── express/ ← real package files
│ ├── accepts -> ../../accepts@1.3.8/node_modules/accepts
│ └── ... (only what express declared)
├── accepts@1.3.8/node_modules/...
└── ... (each package gets only its declared deps)The mechanical difference is one decision. Both managers walk your package.json, resolve the full transitive graph, and copy or link files into node_modules. They do not agree on the shape.
npm hoists. Since version 3, every package in the resolved tree, including transitive deps, gets installed at the top of node_modules whenever it can.[3] Two transitive deps wanting the same version of react? react goes in once, at the top. The original justification was deduplication: hoist as much as possible to keep the tree shallow.
pnpm refuses to hoist by default. Each package gets exactly the deps it declared, no more. The layout is two layers:
- A content-addressable store, a global cache where every version of every package you've ever installed lives once, named by a hash of its contents. Default location is
~/Library/pnpm/storeon macOS,~/.local/share/pnpm/storeon Linux, and~/AppData/Local/pnpm/storeon Windows. Files are hard-linked into your project so they cost no additional disk space.[1] - A virtual store at
node_modules/.pnpm/. For each package version, a directory whose ownnode_modulescontains symlinks only to the deps that package actually declared. Then, at the top ofnode_modules, a single symlink per direct dep into the virtual store.[2]
The trick that makes this work is one Node.js detail: Node's module resolution ignores symlinks.[2] When react is required from inside next/dist/index.js, Node looks for it relative to the real file location, not the symlinked one. So if next declared react as a peer dep, the symlink graph supplies it. If nextdidn't declare it, there is nothing at the right path, and the require fails.
What npm and pnpm put on disk
npm flattens. pnpm builds a graph that mirrors your package.json.
Same input
- package.jsonDeclares your direct deps
- Lockfilepackage-lock.json or pnpm-lock.yaml
- Transitive treeWhat your deps depend on
Two install strategies
Same input, different on-disk shape
Different output
- npm: flat node_modulesEvery package, including transitive deps, hoisted to the top whenever possible
- pnpm: CAS + symlink graphGlobal store of every package version. Symlinks compose the dep tree per package
The phantom-dep prevention is not pnpm being clever. It's pnpm refusing to put things on disk that your package.json didn't ask for.
Takeaway
npm's flat tree is what the rest of the JavaScript ecosystem assumes you have. pnpm's symlink graph is what your package.json actually says.
Phantom dependencies, in plain English
Here's the smallest version of the bug. A package whose code uses lodash.merge:
import merge from 'lodash.merge'
export function deepCopy<T>(a: T, b: Partial<T>): T {
return merge({}, a, b)
}{
"name": "app",
"dependencies": {
"express": "^4.18.0"
}
}The author never put lodash.merge in dependencies. But Express has a chain of transitive deps, and somewhere in that chain lodash.merge shows up. With npm, lodash.merge lands at node_modules/lodash.merge/ via hoisting. The TypeScript compiler finds it. Node finds it. Tests pass. Build ships.
Then six months later, Express bumps a transitive dep that no longer pulls lodash.merge. Or someone runs npm install on a clean machine and the resolution order shuffles. Or the deploy machine resolves a slightly different tree than the dev laptop. The lodash.merge directory disappears from the flat tree. The require fails. Production breaks.
With pnpm, the same code never works on day one. lodash.mergeexists in the global store, but pnpm doesn't hoist it. There's no top-level symlink to it because your package.jsondidn't ask for one. The tests fail immediately, with a clear “module not found” pointing at the real bug, which is that you need to add lodash.merge to package.json.
Receipt
@types/react as a dependency.[5] Under npm, @types/react was usually there anyway, hoisted in by some other package. Under pnpm without --shamefully-hoist, type-checking failed. The bug was on Radix's side, but flat hoisting hid it for years. Same story for Nuxt 3 in June 2022, with vue and ufo.[6]“Using pnpm without --shamefully-hoist is possible today but requires explicitly installing vue and ufo dependencies and we don't want this since Nuxt was always a zero-config package.”
Nuxt fixed it. So did Radix. Both bugs took maintainers' time, and both bugs had been in production for users running Nuxt or Radix on npm. Nobody noticed, because flat hoisting hid them.
I'll be straight about the evidence. There is no neat database of phantom-dep bugs you can quote percentages from. The bug class is invisible until it isn't, and most teams who hit it write a git commitmessage rather than a postmortem. The postmortems that exist are mostly the ones where pnpm exposed something that had been broken for a long time. That's a survivorship problem with the published record, not a problem with the bug.
So here's the strong version of the claim. Most JavaScript projects with more than a handful of dependencies have at least one phantom dep right now and don't know it. The structure of the bug all but guarantees it. Switching to pnpm is the cheapest audit you can run. If you're already running strict-mode TypeScript and treating every implicit-any as a real bug, dependency strictness is the matching upgrade.
Speed and disk are real, but they're the cherry on top
The numbers are real. I'm just not telling you to switch because of them.
The first two numbers are from pnpm's own benchmark page, last run May 3, 2026 against a synthetic project with many transitive deps.[10]That's vendor-published, so the deltas are flattering. I haven't run my own benchmark suite; the order of magnitude lines up with what people on Vercel, Vue, and Nuxt teams have reported.
Disk space is the other speed-adjacent number, and I'm leaving it out on purpose. Every “70% less disk” claim in circulation traces back to pnpm's marketing. The mechanism, hard-linking from a single store, obviously dedupes. The actual savings depend on how many overlapping projects you have on the same drive. One app: small. Thirty-package monorepo or heavy fresh-cloning: large. Run the numbers on your own setup. Don't take a vendor's word for it.
Speed and disk are good reasons to switch. They're poor reasons to leada recommendation, because they're easy to dismiss. “I don't mind waiting twenty more seconds for an install” is a real position. “I don't mind shipping silent dependency bugs to production” isn't.
The lockfile war and the deploy I broke
There's a second way npm lies to you, and it shows up at deploy time, not install time. The repo this article is published on uses pnpm. You can tell because of one warning, in capital letters, in the project's CLAUDE.md:
Receipt
packageManager in package.json). Do not run npm install, it will create a package-lock.json that competes with pnpm-lock.yaml on Vercel and break the deploy.I wrote that warning after I broke the deploy. I'd been working in a different repo, came back here, ran npm installout of muscle memory, didn't notice the new package-lock.json, committed everything, pushed. Vercel built. The build was green. The deploy went out. Some pages started rendering with subtly different React versions in different chunks. By the time I noticed, I'd been debugging hydration mismatches in the wrong place for an hour.
What happened is that Vercel's deploy build looks at your repo and detects the package manager from the lockfile.[12] When both pnpm-lock.yaml and package-lock.json exist, Vercel's documented detection order picks pnpm. Silently. No warning, no error, no banner that says “you have two lockfiles, you might want to look at this.” pnpm built node_modules from pnpm-lock.yaml while my local dev had been using package-lock.json. Two different version trees from one repo. Both green. Different bugs in production than in dev.
The silent failure mode
How a mixed-lockfile bug surfaces
- Day 0, morning
You run npm install in a pnpm repo
package-lock.jsonappears next topnpm-lock.yaml. Both are committed. Locally, everything works because npm built yournode_modules. - Day 0, deploy
Vercel detects pnpm from the lockfile precedence order
Vercel installs from
pnpm-lock.yaml. Build passes. You see green. - Day 0, prod
node_modules in prod no longer matches dev
Different transitive resolutions. Subtle behavior changes that don't trip your tests, because your tests run against your local node_modules, which is the npm one.
- Day N
A real bug surfaces
Hydration mismatch, a missing peer dep at runtime, a version skew in a util library. The cause is invisible from logs because both versions of the dep "exist" in their respective trees.
Takeaway
The single-lockfile rule is more important than which lockfile you pick. Don't let two managers compete in the same repo.
If you're thinking “but Vercel has a packageManager field in package.jsonyou can pin,” yes. Corepack, the bundled tool that reads that field and enforces which package manager actually runs, can wire it up.[13] Corepack also gets removed from Node.js in version 25, so the future story is worse than the present one for any team that pinned through Corepack and forgot it. The official Vercel mitigation is an environment variable called ENABLE_EXPERIMENTAL_COREPACK.[12]The word “experimental” is doing a lot of work in that name.
It's not just Vercel. Netlify, Cloudflare Pages, AWS Amplify, and GitHub Actions all do lockfile-presence detection. AWS Amplify has an open issue titled, plainly, “amplify always chooses the wrong package manager.”[17] Next.js has a Vercel cache-poisoning bug from misdetected package-manager changes that users were still hitting in production months after it was first reported.[18] Install reliability is one of the silent axes on the developer-experience scorecard teams forget to grade themselves on.
The fix to my deploy bug was one git rm package-lock.json and a CLAUDE.md line warning future-me. The fix is cheap. The bug isn't.
The honest counter: where pnpm makes you bleed
I owe you the steelman. pnpm is the right default in 2026 for most teams. It is not the right default for all teams, and it gets in your way in three specific places.
Tools that assume flat node_modules. This is the meaningful pain. Bundlers and build tools written before pnpm's layout was widespread sometimes can't follow symlinks correctly. The pnpm config flag --shamefully-hoist exists for these cases.[7]It collapses pnpm's strict tree back into a flat one. Zoltan Kochan shipped the flag in pull request #2006 in September 2019. The PR description, in full, is empty.[8]Kochan added the editorial word “shamefully” to the flag name and shipped the escape hatch with no rationale text, which is the only honest framing of how he felt about being forced to ship it. Most people, he later wrote, “won't bother to search for the right pattern, they just hoist everything.”[9]
In 2024 to 2026, the tools that historically forced this flag (Webpack, Metro, certain Vaadin and Nx setups) mostly grew real symlink support. But if your stack lands on something old that can't, you'll know: the install completes and your build fails with a module-resolution error. The fix is --shamefully-hoist, which gives you back the npm behavior, with all its phantom-dep downside. Treat that flag as load-bearing technical debt, not a long-term setting.
Bun is real, and it's faster. Bun shipped isolated installs as the default for new workspace projects in version 1.3.2.[14]The architecture is the same as pnpm's, a central store with symlinks, and Bun's installs are noticeably faster than pnpm's on real benchmarks. Bun also runs your code, which means in a Bun-only stack you trade a Node-plus-pnpm pair for one tool. The catches: Bun still has a small compatibility tail that surfaces around native C++ addons and certain long-running Next.js scenarios, and Bun is now Anthropic-owned. The single-vendor risk is real: a runtime that one company controls is one acquisition target, one strategic pivot, or one pricing decision away from being something other than what you signed up for. Node and pnpm don't carry that. If you're greenfield, your dep set is mainstream JS/TS, and you can live with that risk, Bun is the better recommendation than pnpm. For everyone else, pnpm is the boring choice, and boring is what you want from the tool that resolves your dependencies.
Yarn Berry with nodeLinker pnpm. Yarn 4 ships with a configurable install strategy controlled by the nodeLinker setting. Setting it to pnpm produces “a node-modules[that] will be created using symlinks and hardlinks to a global content-addressable store”[15]with the same on-disk shape as pnpm and the same phantom-dep protection. If your team is already on Yarn 4, switching to pnpm is busywork. The trickier question is the Yarn loyalist still running PnP (Plug'n'Play, Yarn's in-memory resolver that skips node_modules entirely). PnP is stricter than pnpm, but every IDE, type-checker, and framework has to explicitly support it, and React Native still requires the node_modules fallback. PnP is the right call for teams whose tooling already plays nice with it, and a long road for everyone else. pnpm splits the difference.
A footnote, not an option: npm's --install-strategy=linked. npm 9 shipped an isolated install strategy three years ago and it has been “experimental” the entire time.[16]The RFC (Request for Comments) discussion has been quiet since December 2023. Nobody's defaulting to it. I wouldn't bet a production stack on it.
The argument for switching to pnpm is not that no other isolated-store package manager exists. Bun and Yarn 4 with nodeLinker: pnpm are real. The argument is that pnpm is the most boring of the four good options, and the package manager is the one piece of your platform stack you should be most boring about.
What to do Monday morning
If you're convinced and on a fresh project, three commands:
# Install pnpm globally (or use Corepack while it lasts)
npm install -g pnpm
# In your project, replace npm with pnpm
pnpm import # converts package-lock.json into pnpm-lock.yaml
rm package-lock.json
pnpm installCommit pnpm-lock.yaml, delete package-lock.json from version control, and add a CI check that fails the build if package-lock.json reappears.
If you're convinced and on an existing project, the migration path looks similar, but the first pnpm installwill surface every phantom dep in your codebase as a build or runtime error. That's the point. Each one is a real bug you've been carrying. Don't reach for --shamefully-hoist to make them go away. Add the missing entries to package.json and re-install.
Three things to watch for in the first week:
- A build-only or test-only tool that breaks because it can't follow symlinks. Either upgrade the tool, or set
--shamefully-hoistonly if there is no upgrade path. If you do, leave a comment with the upgrade-blocker so future-you remembers why. - Your CI cache.GitHub Actions' setup-node action defaults to npm/yarn detection. You'll need
cache: 'pnpm'on the action. - A teammate runs npm install once. They will. Add the warning. A pre-commit hook that fails when both lockfiles exist is two lines and a real defense.
Switch your next project. The first thing that breaks is the bug npm was carrying.
Sources and further reading
- 1.Primarypnpm motivation: content-addressable store, hard-links. pnpm.io
- 2.Primarypnpm's symlinked node_modules structure. pnpm.io
- 3.Primarynpm folders: hoisting since v3. docs.npmjs.com
- 4.PrimaryRush: phantom dependencies (term origin, Microsoft). rushjs.io
- 5.Reporting@radix-ui packages cause compiler errors due to phantom dependency on @types/react. GitHub, Jan 20, 2023
- 6.ReportingNuxt 3: phantom dependencies on vue and ufo (Pooya Parsa). GitHub, Jun 10, 2022
- 7.Primarypnpm settings: shamefully-hoist. pnpm.io
- 8.Primarypnpm PR #2006: rename shamefully-flatten to shamefully-hoist. Zoltan Kochan, Sep 10-11, 2019
- 9.PrimaryKochan on shamefully-hoist's history. pnpm GitHub Discussions, Jan 2024
- 10.Datapnpm benchmarks (auto-updated; numbers as of May 3, 2026). pnpm.io
- 11.Datapnpm monthly downloads from the npm registry. api.npmjs.org
- 12.PrimaryVercel: package manager detection and precedence. vercel.com
- 13.PrimaryNode.js Corepack: shipping with Node up to v25. github.com/nodejs/corepack
- 14.PrimaryBun isolated installs (default since v1.3.2). bun.com
- 15.PrimaryYarn Berry config: nodeLinker. yarnpkg.com
- 16.Primarynpm config: install-strategy=linked. docs.npmjs.com
- 17.ReportingAWS Amplify: amplify always chooses the wrong package manager. GitHub
- 18.ReportingNext.js: persistent build cache issue from package-manager misdetection. GitHub Discussions
Written by
Tech Talk News Editorial
Tech Talk News covers engineering, AI, and tech investing for people who build and invest in technology.