DEV Community

Cover image for pnpm vs npm vs yarn in 2026: I ran all three on my real monorepo and it forced me to change my mind
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

pnpm vs npm vs yarn in 2026: I ran all three on my real monorepo and it forced me to change my mind

pnpm vs npm vs yarn in 2026: I ran all three on my real monorepo and it forced me to change my mind

The correct answer for speeding up installs in a monorepo is to make hoisting stricter. I know that sounds backwards. More strictness should mean more compatibility errors, more debugging time, more friction. And yet that's exactly what pushed me to adopt pnpm — after it first broke a Radix UI dependency at the worst possible moment.

That's the honest trade-off no synthetic benchmark shows you: pnpm is faster and leaner, but its strict hoisting model has teeth. When it bites, it hurts. And the official migration guide doesn't warn you when it's about to bite.

A few months ago I was deep in a sprint — Next.js 16 monorepo, strict TypeScript, Shadcn/ui, Radix UI, everything running on Railway. I switched from npm to pnpm following the usual benchmarks — you know the ones, they measure a react install with three dependencies on a clean machine. In production, the result was different.

My thesis: pnpm wins the overall comparison in 2026, but the compatibility cost is real and measurable. Yarn Berry is the hardest to justify today. And npm improved so much in v10 that it's no longer the obvious thing to throw out.


pnpm vs npm 2026 in a monorepo: the numbers that actually matter

I ran all three on the same project — a monorepo with two Next.js 16 apps and a shared TypeScript utilities package. Same machine, same clean lockfile, same connection. CI numbers came from Railway with cache disabled to measure the real cold install.

Install time (cold cache, Railway CI)

Package Manager Install time Disk usage (node_modules)
npm 10.9 87s 1.4 GB
yarn berry 4.5 72s 890 MB (PnP mode)
pnpm 9.15 41s 610 MB

pnpm is ~53% faster than npm on a cold install and uses less than half the disk. Yarn Berry with PnP is interesting on disk, but the install time doesn't justify PnP's compatibility cost — which is even more aggressive than pnpm's.

With CI cache warm (the everyday scenario), the gap compresses but doesn't disappear:

# Warm cache — same project, three runs averaged
# npm: ~18s | yarn berry: ~14s | pnpm: ~9s
Enter fullscreen mode Exit fullscreen mode

The case that changed my mind: Radix UI and strict hoisting

This is what benchmarks don't measure. pnpm by default doesn't do flat hoisting like npm. Each package can only import what's declared in its own package.json. In theory, that's correct. In practice, there are dependencies that rely on npm's ghost hoisting — they access packages they never explicitly declared.

It happened to me with a specific version of @radix-ui/react-dialog that internally depended on @radix-ui/react-compose-refs without properly declaring it in its own package.json. npm resolved it silently through flat hoisting. pnpm blew up with a cryptic error:

# Error that showed up in the Next.js 16 build
# Cannot find module '@radix-ui/react-compose-refs'
# Require stack:
#   - node_modules/.pnpm/@radix-ui+react-dialog@1.0.5/node_modules/@radix-ui/react-dialog/dist/index.js

# This is not your bug — it's the dependency failing to declare its own dep
Enter fullscreen mode Exit fullscreen mode

The fix that worked while I waited for the upstream patch:

# .npmrc at the monorepo root
# Enable public hoisting for Radix packages that have this problem
public-hoist-pattern[]=@radix-ui/*
public-hoist-pattern[]=@floating-ui/*
Enter fullscreen mode Exit fullscreen mode

This .npmrc tweak tells pnpm to do public hoisting for those specific scopes, replicating npm's behavior only where it hurts. Not elegant. Pragmatic.


Real pnpm monorepo configuration

If you're going to run pnpm in a Next.js 16 monorepo, this is the configuration that survived production. Not the 10-minute tutorial config — the one that emerged after two weeks of debugging:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  # exclude e2e folders so they don't step on monorepo deps
  - '!**/e2e/**'
Enter fullscreen mode Exit fullscreen mode
# .npmrc — monorepo root
# Public hoisting for packages that treat npm's flat hoisting as a feature
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@radix-ui/*
public-hoist-pattern[]=@floating-ui/*

# Strict mode for everything else — pnpm's default
node-linker=node-modules

# Shamefully hoist: NEVER enable this in production
# shamefully-hoist=true  ← this is giving up; it turns pnpm into expensive npm
Enter fullscreen mode Exit fullscreen mode

The shamefully-hoist=true you see in some tutorials is total surrender. If you enable it, you're running pnpm with npm's behavior — you pay the cost of learning pnpm without taking any of the strictness benefits with you.

Workspace protocol and internal dependencies

// packages/ui/package.json  shared package
{
  "name": "@my-monorepo/ui",
  "version": "0.0.1",
  "dependencies": {
    // workspace:* tells pnpm to resolve from the local workspace
    // never from npm registry  this is critical for development
    "@my-monorepo/utils": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode
// apps/web/package.json
{
  "dependencies": {
    "@my-monorepo/ui": "workspace:*",
    // exact Next.js 16 version  no ranges in production
    "next": "16.0.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

The gotchas no synthetic benchmark measures

1. Lifecycle scripts and pnpm's PATH

pnpm doesn't add dependency binaries to your PATH the same way npm does. If you have scripts that call next or tsc directly in the shell (not via package.json scripts), they'll fail:

# This fails with pnpm if next isn't in your global PATH
$ next build

# This always works — pnpm resolves the workspace binary
$ pnpm next build
# or via package.json script:
# "build": "next build"
Enter fullscreen mode Exit fullscreen mode

2. pnpm dlx vs npx — they're not the same thing

# npx installs and caches globally by default
npx create-next-app@latest my-app

# pnpm dlx installs to a temp directory, no caching
# cleaner, slower on repeat runs
pnpm dlx create-next-app@latest my-app

# For tools you use frequently, install globally:
pnpm add -g @railway/cli
Enter fullscreen mode Exit fullscreen mode

3. Strict TypeScript and implicit re-exports

With strict TypeScript and pnpm, implicit re-exports from poorly-typed packages break earlier — which is actually an advantage disguised as a problem. pnpm forces you to discover implicit dependencies that npm would have never surfaced. That happened to me with a utilities library that was re-exporting lodash types without having it in its own dependencies.

This connects to something I already covered in the post about supply chain attacks in npm vs PyPI: the implicit dependency graph is exactly where the most interesting attack vectors live. pnpm makes that graph explicit. That's uncomfortable at first and valuable afterwards.

4. Railway CI and the pnpm store cache

Railway doesn't cache node_modules by default. With npm that stings a little. With pnpm it stings less because pnpm's store lives separately from the project:

# In your Dockerfile or Railway config
# Cache the pnpm store, not node_modules
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

# The store lives outside the project — cacheable across builds
RUN pnpm config set store-dir /root/.pnpm-store
Enter fullscreen mode Exit fullscreen mode

If you don't set this up, every Railway build does a cold install even when the lockfile didn't change. The separate pnpm store is the feature that impacts CI the most — more than the raw install time itself.

5. Yarn Berry in 2026: who is it even for?

Being honest: I couldn't find a use case in my stack where Yarn Berry was the right answer. PnP breaks more things than pnpm's strict hoisting, the documentation assumes you know exactly what you're doing, and the install time advantage over pnpm isn't enough to justify the friction.

Yarn Berry makes sense if you're coming from a massive monorepo already configured with PnP and you don't want to migrate. If you're starting from scratch today, pnpm is the more direct answer. This isn't tribalism — it's that I couldn't find a single benchmark of my own where Yarn Berry won on something I actually cared about.


Compatibility table with the real stack

Dependency npm 10 yarn berry 4 pnpm 9
Next.js 16 ✅ (with sdk)
Shadcn/ui ⚠️ (PnP quirks) ✅ (with public-hoist)
Radix UI ⚠️ ⚠️ (versions < 1.1.x)
TypeScript 5.7
ESLint 9 ⚠️ ✅ (with public-hoist)
Prisma 6 ⚠️ (postinstall)

⚠️ = works but requires extra configuration not documented in the official README


FAQ: pnpm vs npm 2026 monorepo

Is it worth migrating from npm to pnpm on an existing project?

If the project is already in production and stable, evaluate it by CI cost. If your Railway pipeline (or any CI) runs installs frequently, the ~50% cold install difference adds up to real build hours per month. If the pipeline is short or already has aggressive caching, the urgency drops. The migration itself takes half a day plus two days of debugging edge cases — like the Radix UI one I described above.

What is pnpm's strict hoisting and why does it matter?

In npm, all packages are installed into a flat node_modules. Any package can access any other package, even if it didn't declare it as a dependency. pnpm instead creates a node_modules with symlinks where each package only sees what it declared. This prevents phantom dependencies but breaks packages that rely on npm's flat behavior. The official pnpm documentation explains the model in detail.

Does shamefully-hoist=true solve the compatibility problems?

Technically yes, but it's a partial surrender. If you enable shamefully-hoist=true, pnpm behaves like npm in terms of hoisting — you lose exactly the strictness benefit that makes pnpm valuable. The right alternative is public-hoist-pattern for the specific scopes that have the problem, not enabling global hoisting.

Is Yarn Berry with PnP better than pnpm in large monorepos?

Not in my benchmarks. Yarn Berry PnP has an interesting disk advantage but the compatibility cost is higher than pnpm's. On top of that, TypeScript tooling and IDEs have more stable support for pnpm's model than for PnP. For new monorepos in 2026, pnpm is the more pragmatic bet.

Did npm 10 improve enough that switching isn't worth it?

npm 10 improved quite a bit — workspaces work well, installs are faster than npm 8. But on disk and cold CI, the gap with pnpm is still substantial (610 MB vs 1.4 GB in my case). If you've got everything configured with npm and don't have a concrete disk or build time problem, the migration might not be worth the cost. If you're starting a new project, start it with pnpm.

How do I handle dependency updates in pnpm with a monorepo?

pnpm update --recursive --latest updates all apps and packages in the workspace at once. What I learned to do is run this on a separate branch, run the full build, and review lockfile changes before merging. With strict TypeScript, broken type changes show up in the build — which is exactly the safety net I described in the post about functional programming in TypeScript.


My final take (and what I don't buy about the viral benchmarks)

pnpm wins in 2026. That's not up for debate after seeing the numbers in real production. But the "just migrate and you're done" narrative that circulates in viral HN posts feels dishonest to me — or written by someone who's never run pnpm against Shadcn/ui with an outdated version of Radix UI.

The real cost of adopting pnpm isn't install time or learning the CLI. It's the day something breaks in production because a transitive dependency was relying on flat hoisting and nobody documented it. That day exists. It happened to me. It's fixable — but you need to know it's coming.

What I don't buy: that Yarn Berry is relevant for new projects in 2026 without a very specific use case. And I don't buy that shamefully-hoist=true is a valid solution — it's postponing the problem until someone on the team can't figure out why the monorepo behaves differently in local vs CI.

If you're coming from my same stack (Next.js 16, strict TypeScript, Shadcn/ui, Railway), the migration is worth it. Just do it with your eyes open: configure public-hoist-pattern for the UI scopes, cache the pnpm store in CI, and keep TypeScript strict as your safety net for when hoisting surfaces implicit dependencies. That's exactly what I'd do differently if I were starting from scratch.

In the meantime, if you're seeing weird "module not found" errors after migrating to pnpm, before you panic — check whether the package has its own dependencies properly declared. High probability the problem is upstream, not you.


Primary source:


This article was originally published on juanchi.dev

Top comments (0)