Tech 9 min read

pnpm vs npm vs yarn 2026 tested on a Next.js monorepo: install speed, hoisting gotchas, and supply chain surface

IkesanContents

Cold install on a Next.js 16 + Shadcn/ui + Railway monorepo (no cache, no node_modules): npm 10.9 took 87s, Yarn Berry 4.5 took 72s, pnpm 9.15 took 41s.
Disk usage: npm 1.4GB, Yarn PnP 890MB, pnpm 610MB.
Numbers from a DEV Community benchmark article.

The interesting part is the dependency resolution side.
The author hit a wall when pnpm’s strict dependency resolution exposed undeclared dependencies around @radix-ui/react-dialog.
What npm’s flat hoisting accidentally made visible, pnpm hides.

I ran into something similar when npm install broke and I escaped to pnpm during a React2Shell migration.
That was a crude “npm is down, pnpm works” switch.
This article picks up where that left off: how far do you open npm-compatible escape hatches after moving to pnpm.

Speed differences come from how node_modules is built

pnpm’s official explanation: if 100 projects use the same dependency, npm stores 100 copies; pnpm stores one in a content-addressable store and hard-links it.
npm and Yarn Classic hoist dependencies to the root node_modules, letting source code reach packages not declared in package.json.
pnpm defaults to exposing only direct dependencies at the root.

This difference shows up as breakage before it shows up as speed.
In the Next.js 16 monorepo, an undeclared dependency on @radix-ui/react-compose-refs that npm silently resolved surfaced as Cannot find module after switching to pnpm.
Not a pnpm bug—the upstream package was relying on npm’s flat hoisting.

From the perspective of the person whose build just broke, the technical explanation doesn’t matter.
If a UI library breaks right before production, the cause being an upstream undeclared dependency doesn’t unblock you.

shamefully-hoist finishes the migration but dilutes pnpm

The workaround in the original article was to public-hoist only the broken scopes in .npmrc:

public-hoist-pattern[]=@radix-ui/*
public-hoist-pattern[]=@floating-ui/*

This exposes only the broken scopes in an npm-like location.
Narrower than shamefully-hoist=true.
pnpm also offers nodeLinker=hoisted as an escape hatch, but going that far erases the benefit of catching undeclared dependencies.

What matters during migration isn’t whether pnpm is fast, but which packages forced you to restore hoisting.
If public-hoist-pattern entries keep growing in .npmrc, those scopes are compatibility debt.
If it stops at a few, you’re running pnpm with its strictness intact.

CI install speed depends on store caching

87s vs 41s is the cold install—the slowest possible scenario.
Day-to-day CI often has unchanged lockfiles.
If the previous build’s pnpm store or npm cache persists, full registry fetches drop and the gap narrows.

pnpm’s speed relies on store reuse.
The pnpm store is content-addressable storage (a shared store keyed by file content hashes), reusing downloaded packages via hard links.
If your Dockerfile or CI config wipes the store location every run, you’re repeating cold installs.
The original article explicitly set store-dir for Railway:

ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

RUN pnpm config set store-dir /root/.pnpm-store

Vercel behaves similarly.
When I chased an Astro 6 Vercel build error on this blog, the logs showed Using pnpm@10.x based on project creation date and build script restrictions—I initially suspected pnpm 10.
The actual issue was Astro’s script transform path, but pnpm/cache noise in CI logs makes for convincing red herrings.

Yarn Berry is hard to pick on speed alone

Yarn Berry’s PnP eliminates node_modules entirely, which is strong in theory.
Disk usage is well below npm.
But with a typical frontend-adjacent stack—Next.js, Shadcn/ui, Radix UI, TypeScript, ESLint, Prisma—PnP compatibility friction looks heavier than pnpm’s.

If you already have a large monorepo running on PnP, staying put is a normal decision.
For a new Next.js monorepo where you want “faster than npm but not too far from node_modules assumptions,” pnpm is the easier landing.

Small repos have no reason to leave npm

npm 10 is no longer “too slow to consider.”
The author acknowledges npm 10’s improvements but chose pnpm because the disk usage and cache-less CI install speed gaps were large enough.
For small standalone apps, onboarding-focused tutorials, or repos with legacy npm-only tooling, npm is fine.

If you’re evaluating a pnpm move, here’s what to look at:

What to checkHow to read it for pnpm migration
CI install timeLook at store-cached builds, not just cold install
node_modules sizeBigger gap with more apps/packages sharing duplicates
Cannot find module at buildSuspect undeclared deps or hoisting-dependent packages
.npmrcWatch whether public-hoist-pattern stays at a few scopes
workspace depsInternal packages use workspace:* to skip registry resolution

The real migration is messier.
Even if pnpm install finishes fast, you haven’t won until next build, type-check, lint, Storybook, E2E, and the deploy target’s install logs all pass.

Hoisting differences are also supply chain attack surface

npm’s flat hoisting has security implications too.
Packages sitting flat in node_modules can require() each other freely.
From an attacker’s perspective, npm’s node_modules is a structure where one entry point sees everything.

Say a typosquatted package (a malicious package with a name similar to a legitimate one) gets installed.
Under npm’s flat layout, that package can require() auth libraries, ORMs, env-reading utilities—anything in the same node_modules.
The filesystem puts them at the same level whether declared or not.

Under pnpm’s symlink structure, a package can only require() what’s in its own package.json.
Even if a malicious package gets in, its reach is limited to its declared dependencies.
The hoisting strictness described earlier applies here too.

The more you widen public-hoist-pattern in .npmrc, the more this restriction loosens.
A single UI library scope like @radix-ui/* has minimal impact, but shamefully-hoist=true restores npm-level visibility.
The temptation during migration to “just hoist everything and make it work” is common, but keeping scope-limited hoisting is better from a security standpoint too.

Bun installs fast but its node_modules is the same structure as npm

If we’re comparing npm, Yarn, and pnpm, Bun comes up too.
bun install is a native binary written in Zig; cold install speed can be under half of pnpm’s.
It uses a global cache with hard links, conceptually close to pnpm’s content-addressable store.

But dependency resolution follows npm’s flat hoisting.
require() reaches packages not in package.json.
For the Radix UI case, a package that doesn’t declare @radix-ui/react-compose-refs still works fine with Bun’s install.
Undeclared dependencies that only surface under pnpm stay hidden under Bun too.

Supply chain attack surface in Bun’s node_modules is the same width as npm.
No fine-grained control like pnpm’s public-hoist-pattern.
Everything is flat from the start.

This blog runs on Astro 6 + pnpm 10.
Astro officially supports Bun as both a runtime and package manager.
bun install followed by bun run dev just works.
But switching from pnpm to Bun reverts dependency strictness to npm level.
If all you want is faster CI installs, Bun is a strong option; if you want to keep hoisting control, there’s reason to stay on pnpm.

Bun 1.2 added bun.lock (text format) alongside the original bun.lockb (binary).
Workspaces are supported.
But monorepo workspace integration isn’t as mature as pnpm’s workspace:* protocol.

Bun has another mechanism to reduce supply chain attacks, orthogonal to hoisting.
bun install doesn’t run lifecycle scripts (postinstall, etc.) by default.
npm runs postinstall unconditionally after install.
A common supply chain attack vector is embedding malware deployment code in postinstall.

Packages that need lifecycle scripts go in package.json’s trustedDependencies:

{
  "trustedDependencies": ["@prisma/client", "esbuild"]
}

Any package not listed here has its postinstall ignored.
pnpm has an equivalent option: onlyBuiltDependencies.
npm can stop all scripts with --ignore-scripts, but that also blocks legitimate native builds, making it impractical project-wide.

pnpm restricts “which dependencies your code can reach” via node_modules structure.
Bun restricts “which code runs at install time” via trustedDependencies.
Different attack reduction approaches—one doesn’t replace the other.

Next.js itself is the dominant weight

The benchmark was measured on a Next.js 16 monorepo.
The dominant portion of node_modules at 1.4GB (npm measurement) is Next.js itself and its dependency tree.
The 87s vs 41s install speed difference is framed as a package manager story, but most of what’s being downloaded is framework dependencies.

Changing the package manager speeds up install.
But “what you’re installing” is the dominant variable for both install time and node_modules size.
Astro’s full dependency set fits in a few hundred MB of node_modules.
Even with the same monorepo structure, the starting weight differs dramatically depending on whether the framework is Next.js or Astro.

I run this blog on Astro 6 + pnpm 10, using Next or Nuxt only for the admin/SSR portions that need it.
”pnpm vs npm vs yarn—which one?” is a meaningful benchmark question, but whether you make Next.js the main framework for the entire monorepo has a more fundamental impact on install speed and disk usage.

On the security side, Next.js has had recent CVEs for middleware auth bypass and SSRF.
A framework with a large dependency tree has broad attack surface on a layer separate from hoisting.
Catching undeclared deps through package manager strictness is effective, but the decision of “what goes into dependencies in the first place” sits upstream of that.

References