My laptop yelled at me about disk space on a Tuesday, which is how most of my infrastructure decisions seem to start. I had something like 14 gigs of node_modules scattered across nine project folders, and most of that was the same packages copied over and over, because npm hands every project its own private hoard. I’d known about pnpm for years. I nodded along whenever someone brought it up. I just never bothered, since npm works and swapping package managers to reclaim disk felt like exactly the kind of yak-shaving I tease other people for.
Then I actually switched. It took an afternoon, most of which was me squinting at it suspiciously. Six months later I run pnpm on everything and going back would genuinely annoy me. Here’s what changed, where it bit me, and the one spot where I still reach for plain npm.
What npm actually does with your disk
When you run npm install, npm builds a node_modules folder for that one project and fills it with a full copy of every package in your dependency tree. Do that across a handful of repos and you’ve got the same version of the same library sitting on your disk five times over.
# every project gets its own complete copy
~/code/app-a/node_modules 2.1G
~/code/app-b/node_modules 1.9G
~/code/app-c/node_modules 2.3G
None of those copies know the others exist. React, your bundler, your test runner: duplicated per project. On a single app you never notice. Once you’re juggling a few services and some side projects, it adds up fast, and the numbers get silly. The pnpm docs lay out the reasoning behind a different approach in their motivation page, and it was the first thing that made me stop and reconsider my habit.
How pnpm stores things once
pnpm keeps a single content-addressable store on your machine, usually under ~/.local/share/pnpm/store. Every version of every package you’ve ever installed lives there exactly once. When a project needs a package, pnpm doesn’t copy it. It hard-links the file from the store into that project’s node_modules.
pnpm install
# files live once in the global store
# node_modules becomes a tree of links pointing into that store
The practical result is that installing a package you already have somewhere else costs almost no extra disk. My nine-project mess dropped from roughly 14 gigs to under 3. The first time I ran du -sh afterward I assumed the install had failed.
There’s a deeper structural difference too. npm flattens everything into the top level of node_modules. pnpm builds a nested layout where each package only sees the dependencies it actually declared. That sounds like a detail. It turned out to be the part I cared about most.
The strictness I didn’t know I wanted
Because npm flattens everything, your code can import packages you never asked for. If some library in your tree depends on lodash, npm hoists lodash to the top, and now your own code can import it even though it’s nowhere in your package.json. People call these phantom dependencies, and they’re a quiet time bomb.
// worked fine under npm purely by accident:
// lodash was a sub-dependency of something else
import _ from 'lodash' // never declared in my package.json
That code runs until the day the library that pulled in lodash drops it, and then your build breaks for a reason that has nothing to do with anything you changed. pnpm refuses to play along. If you didn’t declare it, you can’t import it.
# pnpm error, the good kind
Cannot find module 'lodash'
# fix is honest:
pnpm add lodash
The first afternoon, this felt like pnpm being difficult. Three broken imports later I understood it was telling me the truth about what my project depended on. I’d been lying to myself with npm and calling it convenience.
Speed, and whether it actually matters
pnpm is usually faster than npm, especially on repeat installs, because linking from a warm store beats downloading and copying. The pnpm team keeps public benchmarks if you want the charts. I’ll be honest about my own experience though: a cold install on a fresh machine isn’t magic, you still pay to fetch packages the first time. The win shows up in CI and in the tenth install of the day, not the first.
In CI the trick is caching the store rather than node_modules. Cache ~/.local/share/pnpm/store, and every job after the first reuses it. My pipeline install step went from a minute and change to about fifteen seconds once the cache warmed up. That compounds when you’re pushing twenty times a day.
Workspaces without the footguns
Monorepos are where pnpm stopped being a nice-to-have and became the reason I won’t switch back. You define your packages in one file:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Internal packages link to each other through the store, so a shared ui package is available to every app without publishing anything or copying files around. The strictness from earlier matters even more here, since it stops one app from accidentally leaning on another app’s dependencies. I wrote about how I decide between one repo and many in my post on monorepo versus polyrepo, and pnpm is doing most of the quiet work that makes the monorepo side livable. The official workspace docs cover the filtering commands, which are the part you’ll actually use day to day.
Where I still reach for npm
I’m not a zealot about this. A fair amount of tooling still assumes npm in its setup instructions, and some Docker base images and CI templates ship with npm wired in. For a throwaway script or a quick npx one-off, I don’t bother setting up pnpm. And if I’m contributing to a project that already standardized on npm, I use what the team uses, because fighting someone’s lockfile is a bad way to say hello. I help teams sort out exactly these kinds of build and tooling decisions in my consulting work, and the answer is almost never “rip everything out on day one.”
The point isn’t that npm is bad. It’s that pnpm’s defaults happen to match how I actually want a package manager to behave: store things once, tell me the truth about my dependencies, and get out of the way.
What to try this week
Pick one project you don’t care about breaking. Delete its node_modules and lockfile, run pnpm import to convert the existing package-lock.json, then pnpm install. Try to build and run it. If something can’t find a module, that’s a phantom dependency you’ve been carrying, and now you know. Spend ten minutes adding the real declarations, commit, and see how your node_modules size looks afterward. If you like what you see, point pnpm at a second project. That’s the whole on-ramp.