Node.js's phantom dependency problem and the path to a 5x faster package manager

colinmcd1 pts0 comments

Node’s phantom dependency problem and the path to a 5x faster package manager — Nub

← BlogTable of Contents

Most package managers across all modern language ecosystems — Rust's Cargo, Python's uv — have converged on the pattern of a global store, and JavaScript's are no exception.

npm fills ~/.npm

pnpm keeps a content-addressable store under ~/.local/share/pnpm

Bun fills ~/.bun

A package version is downloaded from its registry once per machine.

The differences are all in what happens next — how the files get from that cache into node_modules, the directory inside each project that Node actually loads code from. Classic npm copies them: the whole dependency graph, physically, into every project, flattened, one version of each package at the top, with only version conflicts nesting deeper:

my-app/<br>└── node_modules/ # real files, one full copy per project<br>├── ms/<br>├── react/<br>└── … # everything else, flattened

That layout is unsound : your code — and the code of every package in the tree — can import anything sitting at the top, whether or not it was ever declared as a dependency.

pnpm fixes this for your code. It moves every package into a virtual store — a directory tucked inside the project at node_modules/.pnpm:

my-app/<br>└── node_modules/<br>├── ms → .pnpm/ms@2.1.3<br>└── .pnpm/ # the virtual store, one per project<br>└── ms@2.1.3/ # real files, hardlinked in

The top level of node_modules holds only what you declared — your code physically cannot import a package you didn't declare. (Inside the store it's a different story; hold that thought.)

But ultimately the node_modules/.pnpm store is still constructed file-by-file by hardlinking from the global store (tens of thousands of link calls for a typical app). ("Hardlink" here and throughout means hardlink, clonefile, or whatever is fastest on your OS.) At which point the question asks itself: the files are already on disk, in the machine store — why hardlink them all into every project?

The holy grail: symlinks

Why not put the virtual store itself in the machine cache?

~/.cache/store/ # the virtual store, machine-global<br>├── ms@2.1.3/ # real files, one copy per machine<br>└── react@19.2.0/ # one entry per package version

Then node_modules needs no hardlinks at all — just one symlink per package, pointing straight into the store:

my-app/<br>└── node_modules/<br>├── ms → ~/.cache/store/ms@2.1.3<br>└── react → ~/.cache/store/react@19.2.0

No package manager works that way today. Not for lack of trying:

Bun shipped exactly this as the default for its isolated linker (oven-sh/bun#29489) — and flipped it to opt-in three weeks later, because it "changes module-resolution realpaths in a way that can surprise tooling" (oven-sh/bun#30473).

pnpm ships it behind the enableGlobalVirtualStore flag (pnpm/pnpm#8190, closing a request as old as pnpm/pnpm#1001) — off by default, and the tracker shows why: transitive-resolution failures in real Angular and Nuxt projects (pnpm/pnpm#12437), packages the store can't find (pnpm/pnpm#9618), broken TypeScript inference (pnpm/pnpm#9739), concurrent-install races (pnpm/pnpm#10232).

Why does a seemingly obvious idea keep not working? Because of Node.js's phantom dependency problem.

The phantom dependency problem

A phantom dependency is an import of a package the importer doesn't declare in its package.json. Many old, unmaintained, and regrettably load-bearing packages in the NPM ecosystem rely on phantom dependencies.

PackageDownloads/weekPhantom-importses-abstract78Mfor-eachjest41Mjest-configpreact22Mpreact-render-to-stringreact-i18next13Mbabel-plugin-macros@firebase/database12M@firebase/appyjs5.9My-protocolsobject.hasown4.6Mcall-bind

For instance, this is @inkjs/ui, as published to npm:

import React from 'react'; // ← not in @inkjs/ui's dependencies

To resolve import 'react', Node starts at the importing file and walks up the directory tree, checking every node_modules directory it passes for a react folder. So whether that import works is purely an accident of layout:

Flat npm layout — works by accident: everything sits in one flat node_modules, so the walk finds react there.

pnpm — still works. This is the different story inside the store: a hidden fallback tree (node_modules/.pnpm/node_modules, every package linked flat) deliberately keeps your dependencies' phantom imports resolving.

Machine-global store — throws.

It throws because the store is a sealed world : each entry holds one package's real files, plus symlinks to its declared dependencies — and nothing else.

And Node always resolves a symlink to its real path before loading the file. So code loaded from the store resolves its imports from inside the store — your project's node_modules is never even on the walk:

~/.cache/store/ms@2.1.3/node_modules/ms/index.js # the importing file<br>~/.cache/store/ms@2.1.3/node_modules/ # the deps ms declares<br>~/.cache/store/… # nothing of yours up here

Declared imports resolve perfectly; a phantom import finds nothing. This is what...

store pnpm package node_modules react phantom

Related Articles