DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on Originally published at juanchi.dev

pnpm wins in monorepos, npm wins on zero friction

Run du -sh node_modules on two projects: a small one with fifteen dependencies, and a monorepo with four apps sharing internal packages. On the first one, you won't notice any difference between npm and pnpm. On the second one, if you're still on npm, you're going to have the same copy of React, TypeScript and the same devDependencies repeated four times on disk — once for every workspace package that declares them.

That's the real problem. It's not "pnpm is faster" in the abstract. It's that the space and time savings depend on how much duplication your original install had, and that duplication grows with the number of packages in the monorepo, not with the total number of dependencies.

My thesis: pnpm wins in monorepos and in CI because of the symlink model, but npm is still the zero-friction choice for small projects where the learning curve isn't worth it. This isn't an aesthetic preference. It's a calculation that changes depending on the size of the repo.

The real difference between npm and pnpm: what each one stores on disk

npm installs every dependency as a physical copy inside node_modules. If you have four packages in a workspace and all four depend on lodash@4.17.21, npm — unless you use aggressive hoisting, which brings its own phantom-dependency problems — can end up with that same version copied in more than one place in the tree.

pnpm does something different by design. According to the official pnpm motivation docs, the package gets downloaded once and stored in a global content-addressable store, located outside the project. Every node_modules in every package of the monorepo doesn't hold a copy: it holds a symlink pointing to that shared store.

The practical consequence: if you have ten projects on the machine using the same version of a library, that version exists exactly once on disk. All ten node_modules point to the same place.

# See where pnpm's global store lives
pnpm store path

# See how much the store saves compared to a non-deduplicated install
pnpm store status
Enter fullscreen mode Exit fullscreen mode

This isn't magic pnpm invented out of nowhere — it's the same principle OS package managers use with hardlinks, applied to Node. The pnpm documentation is clear about what the model is designed for: avoiding duplication of identical bytes on disk when there are multiple projects or multiple packages within the same repo.

What that source doesn't say is that this will save you build time in every scenario. It saves disk space consistently. The install-time savings depend on whether the package is already in the local store — on a CI run with a cold cache, the first download weighs the same either way.

Where people get it wrong: migrating for hype, not necessity

The recipe I see over and over: small Next.js project, someone reads a thread about pnpm, migrates the lockfile, deletes node_modules, runs pnpm install, and done — "now we're faster." The hidden cost shows up later, not in the project itself but in the rest of the stack that assumes npm: some CI scripts, some Docker images with RUN npm ci, some dependency linter that parses package-lock.json in the format described in the official npm documentation and has no idea what to do with pnpm-lock.yaml.

The counterexample: a project with a single package.json, no workspaces, ten production dependencies. There, the difference between npm and pnpm in install time is marginal, and the symlink model has nothing to deduplicate — there's no other package in the same repo asking for the same library. Migrating that project to pnpm brings no measurable savings; it brings a new tool the team has to learn to debug when something breaks.

This connects to something I already talked about with Docker orchestration: the friction of a tooling migration isn't in the install command. It's in every place where someone assumed the old behavior without documenting it.

Decision matrix: when pnpm matters and when it's noise

Scenario Does pnpm save anything real? What to check first
Monorepo with 3+ packages sharing dependencies Yes — fewer duplicated bytes on disk How many dependencies are shared between packages
CI with dependency caching between runs Yes, if the runner supports caching the global store Pipeline cache config, not just the lockfile
Single project, no workspaces Marginal Whether the team already knows pnpm or needs training
Docker images with multistage builds Depends — you need to adjust the Dockerfile to copy the store Whether the image size savings justify changing the recipe
Team with npm-specific scripts (npm run, npx) in an already mature CI/CD No, unless they plan to rewrite those scripts Rewrite cost vs expected benefit

This matrix doesn't replace measuring on your own project. Each row is a starting point to look at, not a closed conclusion.

The limits of this comparison

I don't have my own benchmark with install-time numbers to publish here, and I'm not going to make one up. What can be stated with the available source is the model — content-addressable store with symlinks — and from that it logically follows that deduplication scales with the number of packages in the workspace. What you can't conclude without running a reproducible experiment on an actual project:

  • Exactly how much time pnpm saves in a CI run: depends on the runner, the cache size, the network.
  • Whether the disk space savings justify the cost of rewriting Docker config or linters that depend on the lockfile format.
  • How it behaves with native dependencies that compile different binaries per symlink — that's a case where pnpm's model can create extra friction this post doesn't cover.

If you need that number for a team decision, the only honest way to get it is to run pnpm install and npm install on the same project, with the same cold cache, and measure it yourself. Nobody else can hand you a valid number for your specific case.

Monorepos, workspaces, and why the model matters more there

In a monorepo with TypeScript and Next.js 16, where several apps share an internal UI package or a set of types, the question isn't "which one installs faster" but "how well does it handle cross-references between workspace packages." pnpm solves this with workspace:* in each package's package.json, and the symlink points straight to the sibling package in the monorepo — without going through the registry.

flowchart LR
  A[paquete-ui] -->|symlink workspace| B[store pnpm global]
  C[app-web] -->|symlink workspace| A
  D[app-admin] -->|symlink workspace| A
  B -->|una sola copia| E[(react, typescript, etc)]

npm also supports workspaces since v7, with a hoisting model toward the monorepo root. It works, but it doesn't deduplicate between different monorepos on the same machine — each repo re-downloads and stores its own physical copy again. That's the fundamental difference between npm and pnpm: it's not a feature one has and the other doesn't, it's an architectural decision about where the byte lives.

If the project doesn't have that problem — because it's a single package, no submodules, no sibling apps — pnpm's model has nothing to optimize. The team's learning curve, understanding .pnpmfile.cjs, understanding why some phantom dependencies that used to "sneak through" with npm now fail with ERR_PNPM_NO_MATCHING_VERSION because pnpm is stricter about undeclared dependencies — all of that is real cost you need to weigh.

FAQ

Is pnpm compatible with existing npm scripts?
Yes, generally pnpm run executes the same scripts defined in package.json. Problems show up in scripts that assume phantom dependencies — packages that aren't declared but that npm left accessible through hoisting. pnpm is stricter and those scripts can break.

Can I have package-lock.json and pnpm-lock.yaml at the same time?
Technically the files can coexist, but mixing package managers in the same project causes inconsistent installs between team members. Pick one and delete the other's lockfile.

Is pnpm worth it for a small Next.js project without a monorepo?
It works fine, but the space and time savings that motivate the switch depend on deduplication between packages — something a single project doesn't have to take advantage of. There the decision comes down to team preference, not a measurable gain.

Does pnpm's content-addressable store break anything with native dependencies?
It can create friction with packages that compile native binaries (node-gyp) because the symlink points to a shared location. It's not a universal blocker, but it's a case worth testing before migrating a project that depends on those libraries.

How do I migrate a project from npm to pnpm without breaking CI?
Run pnpm import to generate the pnpm-lock.yaml from the existing package-lock.json, update the CI steps that invoke npm ci to pnpm install --frozen-lockfile, and review any Dockerfile that assumes npm's flat node_modules structure.

Does Yarn factor into this comparison?
Yarn Berry (v2+) also uses a different model from classic npm, with optional Plug'n'Play. I'm not covering it in detail here because the focus of this post is specifically pnpm's store model versus npm's tree, but the same question — how much duplication does the actual project have? — applies just the same.

Final take

If the project is a monorepo with packages that overlap in dependencies, or if the CI pipeline runs repeated installs every day, pnpm's symlink model isn't hype — it's an architectural decision that verifiably cuts duplicated bytes, as pnpm's own documentation describes. If the project is small and standalone, pnpm's learning curve — its stricter rules, its different error messages, the need for the whole team to have it installed — doesn't pay for itself.

My practical recommendation: don't migrate because you saw it in a thread. Count how many workspace packages share dependencies. If that number is zero or one, stay on npm. If it's three or more, run the experiment — pnpm import, measure it on your own pipeline, and decide with that data, not with mine.


Original sources:


This article was originally published on juanchi.dev

Top comments (0)