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

ON CONFLICT DO UPDATE Is Rewriting Rows You Never Changed
June 19, 2026 · 13 min read

ON CONFLICT DO UPDATE Is Rewriting Rows You Never Changed

A bare INSERT ... ON CONFLICT DO UPDATE rewrites the whole row even when nothing changed. Production numbers showing what that costs, the one-line WHERE clause that stops it, and the 21 call sites where we had to leave the write alone.

Mehdi Abaakouk Mehdi Abaakouk
A Disk Alert, a 392 GB Table, and Indexes Bigger Than the Data
June 14, 2026 · 8 min read

A Disk Alert, a 392 GB Table, and Indexes Bigger Than the Data

A routine Postgres disk alert turned into more than 150 GB of reclaimable waste: blobs nobody reads back, the same JSON copied across 200 million rows, and indexes larger than the data they index.

Mehdi Abaakouk Mehdi Abaakouk
Swapping a Primary Key on a 4M-Row Table: Why I Took 10 Minutes of Downtime
June 11, 2026 · 8 min read

Swapping a Primary Key on a 4M-Row Table: Why I Took 10 Minutes of Downtime

Dropping two columns from a 4-million-row table meant swapping the primary key on the busiest table in our system. Why we skipped the zero-downtime dance and took ten minutes of downtime on purpose.

Thomas Berdy Thomas Berdy