DevLift
Back to Blog

pnpm vs npm — The Content-Addressable Store vs The Familiar Default

Measured: pnpm cut three projects' node_modules from 132 MB to 42 MB and blocked a phantom import npm let through. The real difference is what that single architectural choice compounds into at scale.

Admin
May 22, 20269 min read2 views

pnpm vs npm — The Content-Addressable Store vs The Familiar Default

You clone a new repo. The README says npm install. You run it. Thirty seconds later the terminal stops, and du -sh node_modules tells you it's 1.4 GB. Your colleague's machine — same project, same lockfile — shows 340 MB. She's using pnpm. You've been vaguely meaning to look into that for eight months.

This is that article.

npm and pnpm both install packages from the same registry. Both produce a working node_modules. The difference is how they do it, and that single architectural decision cascades into install speed, disk usage, dependency correctness, and monorepo ergonomics in ways that aren't obvious until you're deep enough to feel them.

Quick Decision Matrix

If your situation is...Choose
Starting a new project from scratch in 2026pnpm
Managing a monorepo with shared internal packagespnpm
Running many Node.js projects on one machinepnpm
CI/CD with cold installs every buildpnpm
Project with legacy packages relying on phantom dependenciesnpm (or investigate first)
Team with zero tolerance for migration frictionnpm
Plugin-heavy ecosystem (some Webpack/CRA plugins)Test pnpm first; npm as fallback
Need universal compatibility, no surprisesnpm
Already using npm workspaces and it's working fineStay put
Greenfield TypeScript monorepo, 5+ packagespnpm

The One Architecture Decision That Drives Everything

npm uses a flat node_modules structure. When you install a package and all of its transitive dependencies, npm hoists them all to the root node_modules directory. This means every package in the tree gets its own physical copy on disk — React, lodash, TypeScript — duplicated for every project you have.

pnpm uses a content-addressable global store + symlinks. Every file from every package version lives once on your disk in the global store — ~/.local/share/pnpm/store on Linux, ~/Library/pnpm/store on macOS, %LOCALAPPDATA%\pnpm\store on Windows. (You may see ~/.pnpm-store mentioned in older posts. That's now only a fallback: pnpm falls back to a .pnpm-store at the filesystem root when your project sits on a different mount than $HOME, since hard links can't cross filesystems. I hit exactly that running these tests from /tmppnpm store path reported /tmp/.pnpm-store/v11. Run pnpm store path to see where yours actually is.)

When you install a project, pnpm creates hard links from your project's node_modules/.pnpm directory to those store files. Your project's apparent node_modules is a symlinked view of those hard links — not copies.

Rendering diagram...

A hard link is not a copy. It's another pointer to the same bytes on disk. Three projects hard-linking the same React version still only consume one React's worth of disk space. If a new version of a package changes only one file out of 80, pnpm adds that one file to the store — not a full second copy.

You can confirm this with stat rather than taking it on faith. I installed the same three dependencies (chalk, lodash, date-fns) into three separate projects with each package manager and compared inodes:

pnpm  pp1/node_modules/.pnpm/lodash@4.17.21/.../package.json   inode 347869  links=4
pnpm  pp2/node_modules/.pnpm/lodash@4.17.21/.../package.json   inode 347869  links=4
      => same inode. one set of bytes, four names pointing at it.
 
npm   np1/node_modules/lodash/package.json                     inode 324791  links=1
npm   np2/node_modules/lodash/package.json                     inode 335065  links=1
      => different inodes. two independent copies.

And the disk cost of those three projects, measured with du (which counts a hard-linked inode once, so this is real disk, not apparent size):

Real disk for 3 projects
npm132 MB (44 MB × 3, no sharing)
pnpm42 MB (projects + shared store combined)

That's a 68% reduction on this sample, which is where the widely-quoted "60–70%" figure comes from. The savings scale with how much your projects overlap: three projects with identical dependencies is close to the best case, and a machine full of unrelated one-off projects will save far less.

Ghost Dependencies: npm's Invisible Footgun

npm's flat hoisting has a side effect that bites teams in production: phantom dependencies (also called ghost dependencies). Because everything gets hoisted to the root node_modules, your code can import any package that any of your dependencies installed — even if you never added it to your package.json.

ghost-dep.js
// Your package.json only lists "axios"
// But axios depends on "form-data", so npm hoists it
// This works with npm — but it shouldn't
import FormData from 'form-data';

This works locally. Then one day axios updates and drops or internalizes form-data. Your build breaks. Not because you changed anything — because a transitive dependency changed. You didn't own that dependency, you just got lucky it was there.

pnpm's node_modules structure uses symlinks that only expose packages you explicitly declared. Trying to import an undeclared package throws a module resolution error immediately, in development, before you ship it.

Here it is happening. is-odd declares a dependency on is-number; my project declares only is-odd and then imports is-number anyway:

$ cat package.json    # dependencies: { "is-odd": "3.0.1" }
$ cat t.mjs           # import n from "is-number"
 
npm  + node t.mjs  ->  PHANTOM IMPORT SUCCEEDED -> function
pnpm + node t.mjs  ->  Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'is-number'

The reason is visible in the directory layout:

npm   node_modules/         is-number  is-odd      <- undeclared dep hoisted to the root
pnpm  node_modules/         is-odd                 <- only what you declared
pnpm  node_modules/.pnpm/node_modules/  is-number  <- reachable by your deps, not by you

Annoying when you first migrate. Priceless six months later when you catch three phantom dependencies that would have become production incidents.

⚠️
pnpm's default is semistrict: hoistPattern defaults to ['*'], which hoists transitive packages into node_modules/.pnpm/node_modules. Your dependencies can still resolve undeclared packages from there — at any depth, not just your direct ones — but your own application code cannot, as the test above shows. For fully strict mode set hoistPattern: [] in pnpm-workspace.yaml. Most teams start semistrict and tighten it once the dust settles.
⚠️
Don't put that setting in .npmrc. As of pnpm 11, .npmrc is read for registry and auth configuration only — pnpm no longer reads non-auth settings from it, and it fails silently: no error, no warning, pnpm config get hoist-pattern returns undefined, and your "strict mode" quietly does nothing. Non-auth settings now live in pnpm-workspace.yaml in camelCase (hoistPattern, not hoist-pattern). This moved in two stages — pnpm 10.6 made it possible, pnpm 11.0 made it mandatory — so plenty of still-current blog posts and Stack Overflow answers give you the .npmrc form.

Install Performance

pnpm publishes a continuously-regenerated benchmark at pnpm.io/benchmarks, which is the honest thing to cite because it's dated, reproducible, and run by people with a stake in the result (so read it accordingly). These are its "lots of files" figures as of 2 August 2026:

Scenarionpmpnpm
Clean (no cache, no lockfile, no node_modules)27.3 s7.5 s
Cache + lockfile (the typical CI case)6.6 s2.0 s
Nothing changed (all three present)1.0 s400 ms
update6.3 s7.3 s

Note the last row: on update, pnpm is slower than npm. That's the kind of thing a vendor benchmark could quietly omit and doesn't, which is a point in its favour.

The gap is biggest on cold installs because pnpm doesn't re-download packages it already has in the global store. Your second project that uses React gets React from the store instantly — and, per the inode test above, without spending disk on it.

In CI, the improvement is more dramatic when you cache the pnpm store rather than node_modules:

.github/workflows/ci.yml
- name: Setup pnpm
  uses: pnpm/action-setup@v6
  # No `version:` — the action reads the `packageManager` field from
  # package.json, which keeps CI and local installs on the same pnpm.
  # If both are set and they disagree, the action errors out.
 
- name: Cache pnpm store
  uses: actions/cache@v4
  with:
    path: ~/.local/share/pnpm/store
    key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
 
- name: Install dependencies
  run: pnpm install --frozen-lockfile

The mechanism isn't algorithmic cleverness — it's that hard-linking from a warm store is essentially free compared to extracting tarballs. Note also that pnpm 11 requires Node 22+, so bump actions/setup-node accordingly.

Workspace and Monorepo Support

pnpm workspaces feel designed by someone who actually runs a monorepo, because they were.

The workspace: protocol is the key difference. When package-a in your monorepo depends on package-b, you reference it as "package-b": "workspace:*". pnpm resolves this to a symlink to the local package, not a published version. When you publish, pnpm automatically rewrites it to the resolved version number.

pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
packages/api/package.json
{
  "name": "@myapp/api",
  "dependencies": {
    "@myapp/shared": "workspace:*",
    "@myapp/types": "workspace:^"
  }
}

Filtering commands let you scope operations to specific packages:

# Install only in the api package
pnpm --filter @myapp/api install
 
# Run build in all packages that changed since main
pnpm --filter '...[origin/main]' build
 
# Run tests in all packages that depend on @myapp/shared
pnpm --filter '...@myapp/shared' test

npm workspaces exist and work, but the workspace: protocol arrived later, filtering is less ergonomic, and the hoisting behavior makes dependency isolation in a monorepo harder to reason about. If you're starting a monorepo today, pnpm is the default choice — Next.js, Vue, Vite, Nuxt, and Astro all use pnpm workspaces internally for exactly this reason.

CLI Differences

The commands are mostly the same. A few worth knowing:

# These work identically
npm install  pnpm install
npm install react  pnpm add react
npm install -D vitest  pnpm add -D vitest
npm uninstall react  pnpm remove react
npm run build  pnpm build  (no "run" needed for non-lifecycle scripts)
npx tsx src/index.ts  pnpm dlx tsx src/index.ts  (or pnpm exec tsx)

The pnpm dlx command is the equivalent of npx — it downloads and runs a package without adding it to your dependencies.

Migrating an Existing Project

The migration is usually a one-liner:

# Delete old node_modules and lockfile
rm -rf node_modules package-lock.json
 
# Install pnpm if you don't have it
npm install -g pnpm
 
# Generate a pnpm lockfile and install
pnpm import   # optional: converts package-lock.json to pnpm-lock.yaml
pnpm install

Where it bites you: packages that relied on phantom dependencies will fail. The error messages are clear, but you'll need to add the missing packages explicitly to your package.json. On a legacy monorepo that's been accumulating cruft for years, expect this to be an afternoon rather than a coffee break.

The escape hatch makes pnpm behave like npm and reinstates phantom dependency access. Don't use it as a permanent solution. Use it to get unblocked, then fix the underlying issues one by one.

pnpm-workspace.yaml
# Temporary escape hatch — treat as tech debt
shamefullyHoist: true
⚠️

The migration gotcha nobody warns you about: since pnpm 10, dependency lifecycle scripts do not run by default — a direct response to supply-chain attacks that abused postinstall. If a package needs to build a native binding, pnpm skips it and tells you. In pnpm 11 this got stricter: strictDepBuilds now defaults to true, so an unapproved build fails the install rather than warning. Run pnpm approve-builds interactively, or declare them:

pnpm-workspace.yaml
allowBuilds:
  esbuild: true
  sharp: true

If you find migration advice mentioning onlyBuiltDependencies or neverBuiltDependencies, it predates pnpm 11 — those were all replaced by the single allowBuilds map.

When to Use npm

  • You're on a small project that doesn't change often and "just works" with npm today
  • Your team has zero patience for migration friction and the current setup is stable
  • You're using packages with native bindings or complex post-install scripts that have known pnpm incompatibilities (rare in 2026, but still exists in some older tooling)
  • You're writing a quick CLI tool or script that you'll run once — the install overhead difference doesn't matter

When to Use pnpm

  • Any new project where you're making the tooling decision for the first time
  • Monorepos — especially once you have more than 2–3 packages with shared dependencies
  • Teams running many Node.js projects on shared CI runners where disk space compounds
  • Projects where dependency correctness matters (catching phantom dependencies early is genuinely valuable)
  • You've been seeing npm installs creep toward 60+ seconds in CI

When to Use Both (Sort Of)

In a team context, "we use npm on some repos and pnpm on others" is a perfectly valid state. The tools aren't interoperable — you can't mix package-lock.json and pnpm-lock.yaml — but you can have a project-by-project policy. Some teams start all new work on pnpm and leave legacy projects on npm until they have a reason to touch them.

The Real Cost Question

pnpm's disk savings matter more than they initially seem, and they compound rather than adding up. The three-project test above went 132 MB → 42 MB; the ratio holds roughly steady as you add projects that share dependencies, because the store is paid for once and everything after that is nearly free. Run du -sh across your own ~/code directory before deciding whether you care.

The phantom dependency protection matters too, but in a different timeline: you won't notice it until the day it saves you. Then it'll be the best 15-minute migration you ever did.

The last thing worth saying: pnpm is no longer the edgy alternative. I checked the repos — Next.js, Vue, Vite, Nuxt and Astro all ship a pnpm-workspace.yaml and a pnpm-lock.yaml, and none of them carries a competing package-lock.json or yarn.lock. The projects that define the Node.js frontend ecosystem run pnpm internally, exclusively. If you're still defaulting to npm out of habit rather than a concrete reason to, that habit is worth revisiting.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
Reading a 2 GB file all at once doesn't run out of memory — it hits V8's 512 MB string limit first. Streams process it in flat memory instead. Here's the pipeline pattern, custom Transform streams, and backpressure, with the numbers measured on Node 22.
AdminAugust 3, 20266 min read
Node.js runs on one CPU core by default. The cluster module lets you fork N workers across all cores — here's the pattern, graceful shutdown, IPC, and when PM2 makes it unnecessary.
AdminAugust 3, 20266 min read