Skip to content
Thomas BerdyThomas 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

Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.
July 17, 2026·8 min read

Our Frontend Was Guessing What Our Parser Accepts. It Was Wrong for 70 of 75 Attributes.

Most of the wrong menus were hiding valid operators, so nobody filed a bug. Instead of patching the heuristic, we made the engine's parser publish its own grammar through the config schema.

Thomas BerdyThomas Berdy
Our Auth Library's Last Release Was July 2022. We Noticed in June 2026.
July 15, 2026·6 min read

Our Auth Library's Last Release Was July 2022. We Noticed in June 2026.

A dead library ran our dashboard's authentication for four years without a single alert, because dependency bots only report new versions and known CVEs.

Thomas BerdyThomas Berdy
Two counters instead of a materialized view
July 3, 2026·10 min read

Two counters instead of a materialized view

Materialized views recompute from source data, but we delete ours within 30 days. Here's the two-writer, two-counter rollup we built on plain Postgres, and the race a boolean dirty flag would have quietly lost.

Rémy DuthuRémy Duthu