Skip to content
Thomas Berdy Thomas Berdy
April 20, 2026 · 5 min read

Switching from npm to pnpm found 3 phantom dependencies in our React app

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-fns is a peer dep of react-date-range. Our app code was importing date-fns directly, and npm’s hoisting had been silently resolving it via react-date-range’s own install.
  • @tanstack/table-core was a types-only phantom: we had a declare module block augmenting its types, but the package was never declared. TypeScript checked happily against something npm had hoisted from a transitive dep.
  • react-is came in through recharts. 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_modules cache step disappeared from every workflow. pnpm’s store cache covers it, and layering both just wasted time restoring duplicate data.
  • pnpm dlx instead of npx. Every npx call in scripts and in playwright.config.ts became pnpm dlx. npx on a pnpm-managed repo reaches into npm’s global cache and produces confusing mismatches.
  • No more - separator. npm needs npm run script -- --flag to forward flags. pnpm passes args directly.
  • Playwright tsconfig. The phantom playwright package vanished once pnpm stopped hoisting it. We now reference @playwright/test directly.

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.

Stay ahead in CI/CD

Blog posts, release news, and automation tips straight in your inbox.

Recommended posts

A Markdown File Became Our Company-Wide On-Call Cheat Code
April 14, 2026 · 11 min read

A Markdown File Became Our Company-Wide On-Call Cheat Code

How a staff engineer turned scattered tribal knowledge into a git repo with Claude Code that lets any team member run a six-system support investigation in two minutes.

Julian Maurin Julian Maurin
Python 3.14 in Production: What PEP 649 Actually Breaks
April 14, 2026 · 8 min read

Python 3.14 in Production: What PEP 649 Actually Breaks

PEP 649 defers annotation evaluation. That's great until FastAPI tries to resolve your TYPE_CHECKING imports at runtime and every endpoint throws NameError.

Thomas Berdy Thomas Berdy
How We Formally Verified Our Merge Queue with TLA+ (and Found Bugs That Tests Missed)
April 13, 2026 · 8 min read

How We Formally Verified Our Merge Queue with TLA+ (and Found Bugs That Tests Missed)

We wrote a TLA+ specification for our merge queue state machine. TLC explored 468,000 states and found two bugs that our test suite and years of production traffic had missed.

Julien Danjou Julien Danjou