Last updated on
Why I chose pnpm over the alternatives
Filed under Frontend & Build
On a normal afternoon I have five or six copies of this site checked out at once. Not five or six clones. Git worktrees: the same repository branched into separate working directories, one per issue I'm touching. Often there's a Claude Code session running in one while I'm off in another, each on its own branch, none of them stepping on the others. And every one of those worktrees shares a single node_modules, symlinked back to the main checkout, so spinning up a new one costs nothing. No install step. The branch is just there, ready to run.
I set that up without thinking hard about the package manager underneath it. Then one day it broke in a very specific way, and I realized the whole arrangement only holds together because of a design decision pnpm made years ago. That's the thing about a package manager. It's the most boring choice in the stack right up until your workflow gets strange enough to lean on it, and mine got strange.
Why the symlink trick is even safe
pnpm keeps a single content-addressable store on your machine. Every version of every package you've installed lives there exactly once, globally. A project's node_modules isn't a pile of copied files. It's a tree of links pointing into that store.
That's what makes the worktree setup cheap. When a worktree symlinks its node_modules to the main checkout, it's borrowing a directory that was already just links into the shared store. Nothing gets copied, nothing gets re-downloaded, and five worktrees don't cost five installs' worth of disk. The trick works at the repo level because it's doing the same thing pnpm already does at the package level: don't duplicate, link.
Try to picture the same setup on a package manager that copies a full tree into every project and you can feel where it gets fragile. pnpm's whole model is built around not doing that, so the workflow I lean on isn't a clever hack fighting the tool. It's the tool's own idea, pushed one level up.
The day strictness earned its keep
The store is the part people talk about. Strictness is the part I'd actually defend.
npm and Yarn's default layout is flat. Everything your dependencies depend on gets hoisted to the top of node_modules, all mixed together, and the practical result is that your code can import a package you never put in your package.json. Something else pulled it in, it landed at the top level, and the import resolves. Nothing warns you.
pnpm doesn't hoist like that. A package can only see what it actually declared. If it isn't in your package.json, you can't import it.
I found out what that's worth on this site. A component imported useFormFields from @payloadcms/ui, and it built fine on my machine for a long time, because that package was sitting in the tree as a transitive dependency of Payload and the import resolved. Then a clean install on Vercel failed: Can't resolve '@payloadcms/ui'. No hoisted tree to lean on, so the phantom import had nothing to resolve against. The fix was to declare @payloadcms/ui as a real dependency at Payload's exact version, which is what it should have been the whole time. On a hoisted layout that bug ships to production and surfaces later, somewhere confusing. pnpm made it fail loudly, on the first honest install, before anyone saw it.
Reproducibility I actually rely on
pnpm-lock.yaml pins the exact resolved version of every package in the graph, direct and transitive. Same lockfile plus same package.json, same tree, on my laptop and in CI. In CI I install with --frozen-lockfile, which refuses to quietly change anything: if the lockfile and the manifest disagree, the install fails instead of resolving to something new that nobody reviewed.
That sounds like paperwork until it saves you. I've had an automated dependency bump nudge one package out of lockstep with the framework that pins it, and CI went red on the spot instead of deploying a mismatched pair. That's the behavior I want. A build that fails honestly beats a build that succeeds by guessing.
Where the same design bites
Here's the part I promised would be honest, and it's the flip side of the opening.
Run pnpm add some-package inside one of those symlinked worktrees and it fails: ERR_PNPM_UNEXPECTED_VIRTUAL_STORE. At first that reads like pnpm being difficult. It isn't. My worktrees don't just link to the store, they share one physical node_modules with the main checkout, so a write through that symlink wouldn't touch one project. It would mutate the tree every other worktree is reading at that moment. pnpm refuses because the alternative is quietly corrupting a checkout I'm not even looking at.
The escape hatch turns out to be the same store that caused the pinch. A worktree can build its own real node_modules in about five seconds, because every package is already sitting in the shared store and only has to be linked, not downloaded. I wrote a one-line command that drops the symlink and does exactly that, and after it runs pnpm add behaves like it does anywhere else, changing only that worktree's files. So the honest downside isn't "you can't install in a worktree." It's "you have to tell pnpm you meant to, because it won't guess."
I keep coming back to the fact that the bite and the benefit are the same mechanism. The store-and-link design is what makes my worktrees nearly free to create, what refuses the unsafe shared write, and what makes the fix cost five seconds instead of a fresh download. I would rather have the tool that stops and hands me a clean way forward than the one that shrugs and does something plausible.
The one paragraph about the alternatives
npm is the default, it ships with Node, and it's genuinely fine now, but its hoisted node_modules is the thing that hides phantom dependencies, so it hides the exact bug I care about catching. Modern Yarn is arguably even stricter than pnpm, since its Plug'n'Play mode drops node_modules entirely, but that strictness comes by asking the whole ecosystem to adapt to a world without node_modules, and plenty of tools still don't. pnpm gets most of the same discipline while keeping a real node_modules made of symlinks, so most tools never notice anything changed. Bun is fast in a way that's genuinely exciting and worth watching, but it's younger, and I don't want my dependency tooling to be the adventurous part of my stack.
So did I choose it
Not really, if I'm honest. I didn't sit down with a comparison table and pick a winner. The way I work picked pnpm for me: the parallel worktrees, the throwaway branches, the clean installs that have to be trustworthy because I'm not the only thing committing to this repo. Every rough edge I hit is the same shape as the thing I rely on, which is a good sign that the tool has one coherent idea rather than a pile of features.
One caveat I'll put plainly, because leaving it out would be the dishonest move: the loudest praise pnpm gets is for monorepos, and this site is a single app. I don't run workspaces day to day, so that whole column of its reputation is one I'm taking on trust rather than reporting from experience. Everything above, though, I've actually lived.
Comments
No comments yet. Be the first to comment.