← Blog
The Nub Team

Node.js and the phantom dependency problem; or, how we built a 4x faster package manager

One copy of every package per machine, symlinked into every project — and the mechanisms that make that layout work across the ecosystem.

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/
└── node_modules/     # real files, one full copy per project
    ├── ms/
    ├── react/
    └── …             # 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/
└── node_modules/
    ├── ms  →  .pnpm/ms@2.1.3
    └── .pnpm/                    # the virtual store, one per project
        └── 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?

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

~/.cache/store/        # the virtual store, machine-global
├── ms@2.1.3/          # real files, one copy per machine
└── 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/
└── node_modules/
    ├── ms     →  ~/.cache/store/ms@2.1.3
    └── 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-imports
es-abstract78Mfor-each
jest41Mjest-config
preact22Mpreact-render-to-string
react-i18next13Mbabel-plugin-macros
@firebase/database12M@firebase/app
yjs5.9My-protocols
object.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
~/.cache/store/ms@2.1.3/node_modules/               # the deps ms declares
~/.cache/store/…                                    # nothing of yours up here

Declared imports resolve perfectly; a phantom import finds nothing. This is what sent Bun back to opt-in and fills pnpm's tracker.

Nub found a way to eliminate that breakage — and ships the symlinked machine-global store as its default layout. Here's how.

De-phantoming the virtual store

To solve the phantom problem, Nub inspects every package's actual code. Nub is uniquely positioned to do this: it already contains Oxc, a production-grade JavaScript parser and transpiler.

As each tarball is downloaded and installed from npm, Nub scans that version's published code — walking the module graph from its entry points and checking every static, unguarded import against the dependencies it declares. Published packages are immutable, so each version only ever needs to be scanned once. The scan runs while the installer is busy downloading, so it's effectively free.

Each verdict lands as a tiny sidecar file in the machine store, keyed by the package's content fingerprint:

$ cat ~/.local/share/nub/store/v1/phantom/s2/0b278fff….json
{"has_unguarded_phantom":true,"targets":[{"name":"preact-render-to-string","from_main":false,"from_subpath":true}],"files_analyzed":39}

At link time, a flagged package is ejected: instead of a symlink into the machine store, it's hardlinked into the project as real files, so its resolution walk passes through your project again. @firebase/database — which requires @firebase/app without declaring it — comes out like this:

my-app/
└── node_modules/
    ├── @firebase/database/                            # ejected: hardlinked files, inside the project
    └── react  →  ~/.cache/nub/pm/store/react@19.2.0

Two details make the eject sound

  • All of its ancestors come along. Ejecting only the offender would leave its store-resident importers resolving the shared copy — two real paths, two module instances, a silent singleton split. So everything that imports the flagged package, directly or transitively, is hardlinked into the project with it. The set is small in practice: 0.3–2.1% of the tree, measured on real large dependency graphs.
  • The phantom target gets linked in. Ejecting @firebase/database moves the walk into your project, but @firebase/app still isn't there — you never declared it either. So Nub links it directly inside the ejected package's own node_modules, where the walk finds it first.

If there are no phantom deps detected, everything collapses to the holy grail: pure symlinks.

~/.cache/nub/pm/store/    # the machine-global store
├── ms@2.1.3/             # real files, one copy per machine
├── react@19.2.0/         # one entry per package version
└── …

Inside a project, node_modules is nothing but links into it:

my-app/
└── node_modules/
    ├── ms     →  ~/.cache/nub/pm/store/ms@2.1.3
    └── react  →  ~/.cache/nub/pm/store/react@19.2.0

Frameworks

Every major framework has been driven through the full loop — install, dev server with a real page load, production build, then serving that build — under Nub's default install:

FrameworkWorksLayout under the default install
Vite + Reactglobal store
Vite + Vueglobal store
Vite + Svelteglobal store
Vite + Solidglobal store
Vite + Preactglobal store
Vite + Litglobal store
Astroglobal store
SvelteKitglobal store
VitePressglobal store
Nuxtglobal store
Parcelglobal store
Expoglobal store
Next.js⚠️ GVS disabled
React Native⚠️ GVS disabled

Three of them needed special handling.

Vite

Vite underpins most of the modern framework ecosystem — Astro, Nuxt, SvelteKit, SolidStart, and TanStack Start all dev-serve and build through it — so if a layout doesn't work under Vite, it doesn't work.

By default, Vite refuses to load files from outside your project. This recently changed in Vite 8.1, which now natively supports virtual stores (vitejs/vite#22415). It reads the store's location from node_modules/.modules.yaml (a pnpm convention originally) and adds it to the allow-list automatically.

$ cat node_modules/.modules.yaml
{"virtualStoreDir":"/Users/you/.cache/nub/pm/store"}

Nub backports this fix to earlier versions of Vite during linking via patch-package, effectively adding virtual store support all the way back to Vite 5. This is the vast majority of Vite versions (and, by extension, most JavaScript frameworks) in the wild today.

Next.js

Next.js is one of the two holdouts. Turbopack (written in Rust) canonicalizes every path and confines the module graph to a single project root. Unlike Vite, there is no opt-out mechanism Nub can take advantage of here. When Nub detects Next.js, it disables symlinks/GVS for the entire project, falling back to the classic pnpm hardlink approach node_modules/.store.

React Native

Same story with bare React Native: its default Metro config only sees files whose real path sits inside the project directory, with no way to point it at the store — so Nub applies the same project-wide fallback. (Metro itself isn't the constraint: Expo ships a store-aware Metro config, and Expo apps work fine against the global store.)

Opting out

The global store also switches off wherever it can't work or isn't wanted:

  • CI (CI=1 in the environment), and nub ci anywhere — real directories and relative links, a node_modules that survives a Docker COPY --from into a fresh image where the machine store wouldn't exist.

  • Injected workspace dependencies (dependenciesMeta.*.injected) — these exist to give each consumer its own copy, so the store stays in the project.

  • Manual opt-out — one line, same isolated layout, same phantom protection, with the virtual store back inside the project:

    # .npmrc
    enableGlobalVirtualStore=false

The default, though, is the global store, with the scanner, the Vite compat, and the framework fallback closing the gaps.

Final results

All together, Nub can safely symlink the vast majority of packages into your project-local node_modules, while surgically falling back to a classic hardlink approach for any problematic dependencies. The final result: symlink-based linkers are finally feasible in JavaScript, and warm installs are up to 4x faster.

The fixture: a real TanStack Start starter (Vite + React, ~313 packages), installed warm — store populated, node_modules empty:

warm install · TanStack Start · macOS

nub install171 ms
bun install686 ms · 4× slower
pnpm install3193 ms · 18.7× slower
npm ci5316 ms · 31.1× slower

View benchmark →

See the virtual store docs for the layout options in full.