Skip to main content
← Back to blogs

Why use pnpm?

By AntonioGitHub ↗LinkedIn ↗
CodingWeb DevelopmentFull StackNext.js

I kept hearing pnpm was faster and smaller than npm, but I never understood why. So I dug into how each one actually lays out node_modules, what pnpm's content-addressable store does, and when plain npm is still the better pick.

For a while I kept seeing people say pnpm was faster than npm and used less disk space, and I never really understood why. They're both just package managers. They both read a package.json and download the same packages from the same registry, so where does the difference come from? I switched this portfolio over to pnpm partly to find out.

The short answer is that the magic isn't in how they download. It's in how they store what they downloaded. npm gives every project its own private copy of every dependency. pnpm keeps one shared copy on your machine and points each project at it with links. That one design choice is where the speed, the disk savings, and a stricter dependency tree all come from. The interesting part is everything in between, so let's walk through it.

What do npm and pnpm actually do?

A package manager has a pretty simple job. You list the libraries you want in package.json, and it figures out the full set of packages those libraries need (their dependencies, and their dependencies' dependencies, all the way down), then puts them in a folder called node_modules so Node can find them at runtime. That whole chain is called the transitive dependency tree, and it's usually huge. This portfolio declares about 30 libraries and ends up pulling in over 1,400.

npm's approach is to download all 1,400 of those packages and copy them, as real files, into this project's node_modules. If you have another project on the same machine that also uses React, npm downloads and copies React again into that project's folder too. Think of it like buying a fresh copy of the same book for every room in your house. It works, but you end up with a lot of identical books.

pnpm keeps one library, a shared shelf, and every room just has a note pointing to the shelf. That shared shelf is called the content-addressable store.

What is the content-addressable store?

It's a single folder somewhere on your machine where pnpm keeps exactly one copy of every package version it has ever downloaded, across all your projects. "Content-addressable" just means each file is stored under a key derived from its contents, so two identical files can never be stored twice. Download React 19.2.3 once and it lives in the store once, no matter how many projects use it.

So what's inside a project's node_modules then? Mostly links, not files. Here's what I see in this portfolio. pnpm store path tells me where the shared store lives, and listing a package shows it's a symlink, not a real folder:

bash
$ pnpm store path
D:\.pnpm-store\v10

$ ls node_modules/clsx -la
lrwxrwxrwx clsx -> /d/github/antonio-portfolio/node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/

$ ls node_modules/.pnpm | wc -l
1455

Two kinds of links are doing the work here, and they're worth telling apart. A symlink is a signpost: node_modules/clsx isn't a folder, it's an arrow pointing to where the real clsx files sit, inside a .pnpm folder. A hard link is sneakier: it's a second name for the exact same bytes on disk, so the file inside .pnpm and the file in the shared store are literally the same data, counted once. That's why the numbers come out strange: this project's node_modules reports about 916 MB, and the whole shared store reports about 908 MB, because the project folder is barely storing any real bytes of its own. It's almost all links back to the shelf.

The payoff shows up once you have a few projects. On a laptop with one Node app, you'd never notice. On my /d drive, which had collected 30-odd Next.js projects, npm meant 30 near-identical copies of React, TypeScript, ESLint and Next internals, easily 10 GB of duplicates. pnpm collapses all of that into the one 908 MB store.

Why is pnpm faster?

Same reason. The first time you ever run pnpm install on a brand-new machine, the store is empty, so it has to download everything and it's only a little quicker than npm. The win comes on the second install, anywhere on the machine. The packages are already on the shelf, so there's nothing to download and nothing to unzip. pnpm just creates the links and you're done in seconds.

The everyday version of this is the one I actually feel. When I delete node_modules to chase a weird bug and reinstall, it's back almost instantly instead of grinding through a fresh download. That sounds minor, but it changed my behaviour: reinstalling used to feel expensive enough that I'd avoid it, and now I just do it.

What does a strict node_modules give me?

This is the part I didn't expect to care about and now I do. npm's node_modules is flat: all 1,400 packages sit together at the top level. That means your own code can import something you never installed, as long as it happens to be in there as somebody else's dependency. It works today, and then one day you upgrade the library that was secretly providing it, the package moves, and your import breaks for reasons that look like black magic.

pnpm only puts the libraries you actually declared at the top level. Everything else is tucked away where your code can't reach it by accident. Here's the real package.json of this portfolio, trimmed a little. Every name in this list is something my code genuinely uses:

json
{
  "name": "antonio-portfolio",
  "private": true,
  "dependencies": {
    "@gsap/react": "^2.1.2",
    "@portabletext/react": "^6.0.2",
    "@sanity/client": "^7.15.0",
    "@sentry/nextjs": "^10.39.0",
    "clsx": "^2.1.1",
    "gsap": "^3.14.2",
    "lenis": "^1.3.17",
    "next": "16.1.6",
    "next-sanity": "^12.1.0",
    "posthog-js": "^1.352.1",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "shiki": "^4.0.2",
    "tailwind-merge": "^3.5.0"
  }
}

A short list at the top, 1,400-plus packages hidden underneath in .pnpm. If I try to import one of the hidden ones, pnpm stops me with a clear error instead of letting it slide. The week I switched, that caught two real bugs in my own code: imports of packages that were only there as dependencies of Sanity and Next, both of which would have broken later. Think of it like a pantry where you can only reach the ingredients you bought on purpose, not whatever the delivery happened to leave behind.

What about the commands and the config?

Day to day, the pnpm CLI is almost the same as npm, with a few names that tripped me up at first. Here's the cheat sheet I kept open for the first week:

bash
# npm                          # pnpm
npm install                    pnpm install
npm install foo                pnpm add foo
npm install --save-dev foo     pnpm add -D foo
npm uninstall foo              pnpm remove foo
npm ci                         pnpm install --frozen-lockfile
npm run build                  pnpm build         # 'run' is optional
npm cache clean --force        pnpm store prune

The one that bit me most was reaching for npm install foo to add a package, when in pnpm that's pnpm add foo. The other useful one is pnpm store prune, which is how you reclaim disk: it scans every project on the machine, sees which package versions are still in use, and deletes the rest from the shared store. I run it about once a month and it usually frees a gigabyte or two.

There's also one tiny config file in this single-app project that npm has no equivalent for:

yaml
# pnpm-workspace.yaml
ignoredBuiltDependencies:
  - sharp
  - unrs-resolver

Some packages run a script the moment they install, often to compile a native binary. sharp and unrs-resolver both do this. npm runs those scripts silently and never asks. pnpm refuses to run them unless you list the package as approved, which is a nice safety net against a random dependency executing code at install time. These two lines are me telling pnpm I know about these two and I'm fine with them being left alone.

Things that surprised me

A few things didn't match what I expected going in:

  • The project's node_modules and the whole shared store were almost the same size (916 MB vs 908 MB), because the project folder is nearly all links.
  • pnpm is barely faster on the very first install. The dramatic speedup only kicks in once the store has things in it.
  • The strictness was the feature I valued most, and it was the one I'd underrated completely before switching.
  • The store never cleans itself. Old package versions pile up forever until you run prune, so a neglected store can quietly grow bigger than the npm setup you left.
  • It caught a mistake I'd made in production: I added a package locally, forgot to commit the updated lockfile, and the Vercel build refused to install because the lockfile and package.json disagreed. npm would have shipped a quietly different tree.

When is npm still the better choice?

This isn't a takedown of npm. There are real cases where I'd leave a project on it. If it's the only Node project on a machine, the shared store has nothing to share with, so the disk savings are theoretical and npm already comes bundled with Node. Nothing to install, nothing to learn.

If a project relies on older tooling that expects a flat node_modules (some React Native templates, certain Electron or legacy Webpack setups), pnpm's linked layout can confuse anything that crawls the folder by hand instead of asking Node where a package is. You can usually fix it with node-linker=hoisted in .npmrc, but that throws away the strictness, so at that point you're keeping pnpm just for speed.

And if a project lives on contributions from strangers, an open-source library where people clone it and instinctively type npm install, switching adds a small friction tax to every newcomer. The honest pattern is that every reason to stay on npm is about people or old tooling, not about pnpm being technically worse.

So, why use pnpm?

Here's where I've landed. If you've got more than one Node project on your machine, you're the main person installing things, and you can spend an evening getting used to a slightly different CLI, pnpm is worth it. You get back disk space you didn't know you were losing, a dependency tree that stops you importing things by accident, and installs that feel instant after the first one. Stay on npm if the project is a one-off, if it's a public library that wants to be friendly to drive-by contributors, or if its tooling still expects the old flat layout.

If you're where I was, curious but not sold, you don't need to run any benchmarks. Open a project you already know, run pnpm install, then try to import a package you never added to package.json. The error you get is the whole pitch in one line. The disk savings and the speed are just bonuses you collect later.

Related posts