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.

Tech Talk News Editorial11 min read
ShareXLinkedInRedditEmail
pnpm vs npm: The Real Reason to Switch Isn't Speed

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

The case for pnpm in 2026 is honesty, not speed. npm's flat 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:

node_modules under each managerplaintext
# 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)
Same input, different shape. Under npm, your code can require any package in the flat top tier. Under pnpm, only express is reachable from the top; the rest is reachable only through symlinks that mirror what each package actually declared.

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:

  1. 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/store on macOS, ~/.local/share/pnpm/store on Linux, and ~/AppData/Local/pnpm/store on Windows. Files are hard-linked into your project so they cost no additional disk space.[1]
  2. A virtual store at node_modules/.pnpm/. For each package version, a directory whose own node_modules contains symlinks only to the deps that package actually declared. Then, at the top of node_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 trick that makes pnpm strictNode's module resolution ignores symlinksA package can only require deps in its own real-path node_modules, which is exactly what its package.json declared.

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:

app/util.tsTypeScript
import merge from 'lodash.merge'

export function deepCopy<T>(a: T, b: Partial<T>): T {
  return merge({}, a, b)
}
package.jsonJSON
{
  "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

This isn't a corner case. In January 2023, Radix UI's component library imported React types in its type definitions but didn't declare @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.
Pooya Parsa, Nuxt core, June 2022

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.

9.3s vs 29s
~3x faster
Cold install (May 3, 2026)
2.4s vs 8.4s
~3.5x faster
Warm install with lockfile
~300M
Apr 2026
pnpm monthly downloads

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

This project uses pnpm (pinned via 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

  1. Day 0, morning

    You run npm install in a pnpm repo

    package-lock.json appears next to pnpm-lock.yaml. Both are committed. Locally, everything works because npm built your node_modules.

  2. Day 0, deploy

    Vercel detects pnpm from the lockfile precedence order

    Vercel installs from pnpm-lock.yaml. Build passes. You see green.

  3. 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.

  4. 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:

terminalShell
# 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 install

Commit 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-hoist only 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.

Written by

Tech Talk News Editorial

Tech Talk News covers engineering, AI, and tech investing for people who build and invest in technology.

ShareXLinkedInRedditEmail