Switching from npm to pnpm found 3 phantom dependencies in our React app
A pnpm migration meant to speed up installs ended up exposing three phantom dependencies our React app had been shipping without declaring.
I expected the switch from npm to pnpm to be a weekend cleanup: install faster, cache smaller, move on. Instead the first pnpm install refused to complete. Three packages my app was importing had never been in package.json, and npm had been hoisting them into place for long enough that nobody remembered.
Why we moved at all
Two reasons, both boring. Install was getting long enough to notice, and our GitHub Actions cache was creeping up week after week, dragging restore times with it. I won’t claim specific before/after numbers because I didn’t measure them carefully. The improvement was obvious enough that nobody has asked me to justify it.
The rest of pnpm’s pitch is well-worn at this point. A content-addressable store and hard-linked node_modules, so the same package never duplicates across the dependency tree. I expected speed. The part worth writing about came later.
What pnpm caught
pnpm installs strictly. If a package imports something that isn’t in its dependencies or peerDependencies, pnpm doesn’t hoist a neighbor copy into view. Your code fails to resolve.
We had to add three entries to package.json:
"date-fns": "3.6.0",
"@tanstack/table-core": "8.21.3",
"react-is": "19.2.4"
date-fnsis a peer dep ofreact-date-range. Our app code was importingdate-fnsdirectly, and npm’s hoisting had been silently resolving it viareact-date-range’s own install.@tanstack/table-corewas a types-only phantom: we had adeclare moduleblock augmenting its types, but the package was never declared. TypeScript checked happily against something npm had hoisted from a transitive dep.react-iscame in throughrecharts. Same story: imported, never declared.
These are classic phantom dependencies. Our app was importing packages it didn’t declare. The imports worked because npm hoists everything into a flat top-level node_modules and lets anyone reach anything.
depcheck and npm ls would have caught it
Yes, fair pushback. Tools like depcheck, eslint-plugin-import/no-extraneous-dependencies, or npm ls can flag the same issues. We weren’t running any of them consistently.
The difference with pnpm is that the enforcement is free. It’s not an extra CI step you can forget to run. It’s pnpm install itself, and the audit happens whether you asked for it or not.
How npm hides what pnpm surfaces
npm (hoisted) pnpm (strict)
───────────── ─────────────
node_modules/ node_modules/
├── react-date-range/ ├── react-date-range -> .pnpm/rdr@x.y/...
├── date-fns/ ← hoisted, └── (no date-fns here)
│ app reaches .pnpm/rdr@x.y/node_modules/
│ it by └── (peer dep must come from app)
│ accident
└── (app imports
date-fns)
npm builds a flat forest. Anything hoisted at the top level is resolvable by anyone, including code that never declared it. pnpm builds a strict graph. react-date-range only sees what it declared, and peer deps must come from the root app’s package.json.
npm’s behavior is a footgun. We hadn’t been bitten yet. We would have been, the day one of those parent packages dropped a peer dep or shipped a major.
How corepack pins pnpm in CI and Docker
pnpm ships a packageManager field you set in package.json:
"packageManager": "pnpm@10.33.0"
Corepack reads that field and provisions the exact version at call time. In our Dockerfile, corepack enable runs before any pnpm invocation. In CI and non-interactive contexts we also run corepack prepare pnpm@10.33.0 --activate to skip the download prompt newer Node versions show. That’s the whole setup. No npm i -g pnpm, no version drift between the CI image and the production build, no Dockerfile step that quietly pulls a newer pnpm the day someone rebuilds.
Upgrading pnpm is a one-line bump in package.json. Everyone gets the new version on the next install. For a tool whose job is reproducibility, pinning its own version through the same file it pins everything else is the right default.
Small details that added up
Things I didn’t anticipate:
- GHA cache cleanup. The
node_modulescache step disappeared from every workflow. pnpm’s store cache covers it, and layering both just wasted time restoring duplicate data. pnpm dlxinstead ofnpx. Everynpxcall in scripts and inplaywright.config.tsbecamepnpm dlx.npxon a pnpm-managed repo reaches into npm’s global cache and produces confusing mismatches.- No more
-separator. npm needsnpm run script -- --flagto forward flags. pnpm passes args directly. - Playwright tsconfig. The phantom
playwrightpackage vanished once pnpm stopped hoisting it. We now reference@playwright/testdirectly.
You only notice them grepping through the repo for the fifth time.
Freezing the bots during the rebase
One operational detail worth sharing. A 36k-line diff (mostly lockfile churn) across 14 files is painful to keep rebased when Renovate and Dependabot are pushing updates every few hours. We used our own merge queue freeze feature, scoped to dependency bots only, while the PR was open. Humans could keep merging. Bots got paused.
I’ll admit it felt nice to reach for our own product to solve a migration problem. It also happened to be the fastest way I could think of to stop the noise.
Would I do it again
Yes, and sooner.
The migration itself was mechanical. The valuable part was the audit pnpm forced on us: three undeclared dependencies shipping in production for who knows how long. That’s the argument for pnpm I don’t see often enough. Install speed and disk footprint are nice. Having your package manager refuse to let you ship against a lie is better.