The virtual store
How Nub's default linker works — one symlink per package into a machine-global store, with install-time phantom detection and per-package ejection — its warm-install performance, and the flat and project-local opt-outs.
Every install links an isolated virtual store by default: direct dependencies sit at the top of node_modules, transitive packages link into a per-machine store, and undeclared (phantom) dependencies fail instead of resolving by accident.
One symlink per package
Incumbent package managers build node_modules file by file: npm copies every file into every project, and pnpm and Bun hardlink every file from a machine cache — faster, but still one syscall per file, tens of thousands for a typical app.
Nub's default layout links one symlink per package. Package files live in a machine-global store, one real copy per package version, deduplicated by content hash:
$ nub store path
/Users/you/.local/share/nub/store/v1Each store entry holds a package version's real files plus symlinks to its declared dependencies. The project's node_modules then symlinks each package straight into the store:
my-app/
└── node_modules/
├── ms → ~/.local/share/nub/store/…/ms@2.1.3
└── react → ~/.local/share/nub/store/…/react@19.2.0Two properties follow:
- Warm installs scale with package count, not file count. Linking a project is O(packages) symlinks instead of O(files) hardlinks or copies.
- The store is sealed. Node resolves a symlink to its real path before loading, so code loaded from the store resolves imports from inside its store entry — its declared dependencies and nothing else. An undeclared import fails at resolve time instead of working by accident.
No other package manager ships this as its default. Bun shipped it for its isolated linker and reverted to opt-in three weeks later; pnpm keeps it behind the off-by-default enableGlobalVirtualStore flag. The blocker is the same in both trackers: widely-used packages import phantom dependencies — packages they never declare — and a sealed store breaks them. The history and the mechanism below are in the blog post: how we built a 5x faster package manager.
Phantom detection and ejection
Nub can make this layout the default because the install detects the packages that would break, and only those fall back.
As each tarball is imported into the store, Nub parses that version's published code with Oxc: it walks the module graph from the package's exports, main, and bin entry points and checks every static, unguarded import against the dependencies the package declares. Published versions are immutable, so each verdict is computed once per content fingerprint and cached machine-wide next to the store. The scan runs on the download fan-out threads, overlapped with network time.
At link time, a flagged package is ejected: hardlinked into the project as real files instead of symlinked into the store, so its resolution walk passes through the project again — and the undeclared target is linked where that walk finds it. Everything that transitively imports the flagged package is ejected with it; ejecting the offender alone would leave its store-resident importers loading the shared copy — two real paths, two module instances. The ejected closure measures 0.3–2.1% of real large trees, and the symlinked majority keeps the one-link-per-package relink.
Detection and ejection are on for every install, with no configuration. Three toolchains whose resolvers cannot follow symlinks out of the project — Next.js, whose Turbopack canonicalizes paths and confines the module graph to a single project root; bare React Native, whose Metro config crawls by real path; and Remix 3, whose asset server serves npm packages to the browser only from inside the project's node_modules — automatically get a project-local store instead: the same isolated layout, with every link staying inside node_modules. See shared vs per-project store.
Performance
A warm install — store populated, frozen lockfile — is where the layout's advantage shows: relinking costs O(packages) while every other installer pays O(files), so the gap widens as trees grow.
warm install · 1168 packages · Linux (ubuntu-latest)
hyperfine, 25 runs / 6 warmup, near-idle ubuntu-latest runner · bun 1.3.14, pnpm 10.34.4, npm on Node 24. View benchmark →
The two Nub rows isolate the layout's contribution. With --node-linker hoisted, Nub links Bun's exact flat layout with the same per-file hardlink syscall — 1.3× faster than Bun on the same work. The default row is the same install with the one-symlink-per-package relink. On a thinner 313-package tree the default's lead over Bun is 1.7×.
The flat opt-out
To get a plain, flat node_modules instead — npm's layout, no virtual store — set one line in .npmrc:
node-linker=hoistedFor a single command, without touching config:
nub install --node-linker hoistedNub reads node-linker from .npmrc under every incumbent, including pnpm 11 and later. Files still materialize from the global content store by reflink or hardlink, so a flat tree costs little extra disk.
Layout versus hoist patterns
The node-linker setting picks the layout: isolated (the default symlink tree) or hoisted (flat). It is unrelated to hoist, shamefully-hoist, and public-hoist-pattern, which lift packages to the top level within the isolated layout and leave the virtual store in place.
Per package manager
Nub does not take layout from an incumbent's branded config:
| Incumbent | Its own flat-layout config | Under Nub |
|---|---|---|
| pnpm | nodeLinker: hoisted in pnpm-workspace.yaml or global config.yaml | No effect — set node-linker=hoisted |
| npm | install-strategy=hoisted | No effect — set node-linker=hoisted |
| Yarn | nodeLinker: node-modules in .yarnrc.yml | No effect — set node-linker=hoisted |
| Bun | linker = "hoisted" under [install] in bunfig.toml | No effect — set node-linker=hoisted |
The same rule covers hoisting keys, the modules directory, and virtual-store paths — see layout settings for the full field list. The layout fields in nub.jsonc apply under every incumbent.
Yarn Plug'n'Play is refused rather than ignored, because Nub installs a node_modules tree and a PnP project has none:
$ nub install # ❌
Error: nub: this project is configured for Yarn Plug'n'Play (nodeLinker:
pnp, or Yarn Berry's default) — nub installs a node_modules tree and
doesn't support PnP yet, so the result would diverge from yarn's. Install
with yarn, or set `nodeLinker: node-modules` in .yarnrc.yml.
[ERR_NUB_PNP_UNSUPPORTED]Flat vs project-local
Two settings turn off different halves of the default:
# a flat, npm-style tree — no virtual store at all
node-linker=hoisted
# keep the isolated layout, but move the store inside the project
enableGlobalVirtualStore=falseThe enableGlobalVirtualStore=false form keeps isolation and its phantom-dependency protection; it only relocates the store from the shared per-machine location to node_modules/.store/ inside the project. That makes the tree self-contained — it survives a Docker COPY --from into a fresh image, where the shared store would not exist. Nub already does this automatically in CI and under nub ci. See store and disk layout for the shared-versus-project-local store in full.
Read the full docs on pnpm.io.
Reclaiming disk space
A store entry is keyed by the package's whole resolved dependency graph, so bumping one dependency re-keys that package and every package that reaches it. The entries left behind stay on disk. Collect them with a prune:
nub store prunePruned 4 packages (0.1 MB) from the global virtual store
Pruned 1 stale project record from the global virtual store registry
Pruned 4 files (0.0 MB) from the storeNub records every project that installs against the shared store. A prune keeps the entries those projects still link to — directly, or through another entry's own dependencies — and removes the rest. A registered project whose directory is gone stops counting as a store user; the prune drops its record and says so, and whatever only that project reached goes with it. The next install in a project that needs an entry extracts it again.
--dry-run prints the same plan without removing anything, and --dry-run --json prints it as JSON.
The extracted-tree cache, the copy of each package that the linker clones into projects, is held rather than collected. The first prune to find an entry unreferenced reports it; only a prune that still finds it unreferenced 30 days later removes it. Installing in a project that needs the entry clears the hold.
Holding 30029 entries of the extracted-tree cache unreferenced for 30 days
before removal. Install in any project that still needs them and they are
kept.That delay is what makes the command safe to run on a store you have had for a while. A project only becomes visible to the prune once it has installed at least once, so on the day you upgrade every project you have not touched yet looks unreferenced. Without the hold, one install followed by one prune would delete the rest.
With nothing registered at all, a prune skips that cache rather than treating an empty registry as an empty store:
No projects are registered against the store; skipping the extracted-tree
cache. Run an install in each project you want kept, then prune again.Any install registers a project, including one that reports no work to do. Running an install once in each project registers it.
Earlier versions of Nub kept the shared store in a different layout. A prune removes what that layout left behind once no project links into it, on the same 30-day hold.
Project configuration
Any project can pick the layout with one linker field — global-virtual-store for the shared store (the default), isolated for a project-local one, or hoisted for a flat, real-directory tree.
{
// ...
"install": {
"linker": "isolated"
}
}Two of the strategies accept an extra option, written in the object form; hoisted takes none. Under isolated that option is hoist, which fills the hidden fallback tree at node_modules/.store/node_modules/ — the directory your dependencies reach when they import something they never declared. It takes true, false, or a pattern list.
{
// ...
"install": {
"linker": {
"strategy": "isolated",
"hoist": ["*eslint*", "@types/*"]
}
}
}Under global-virtual-store that option is eject, which writes matching packages into the project as real directories instead of linking them out of the shared store.
{
// ...
"install": {
"linker": {
"strategy": "global-virtual-store",
"eject": ["electron", "@company/native-*"]
}
}
}The two are not interchangeable: hoist fills a directory that only exists when the store is project-local, and a package linked out of a shared store never walks through the project on the way to it. Naming one under the other strategy is rejected.
A related field, publicHoist, is outside linker because it applies under every strategy — it puts packages in the project's own top-level node_modules for tools that resolve from the project root, like TypeScript looking for @types/*.
GYP-based native builds
Some packages that compile native code with node-gyp fail to build under the isolated layout. The trigger is a binding.gyp that names another package as a GYP build-file dependency:
{
"targets": [
{
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"]
}
]
}Every node-gyp run generates with --depth=. and writes into build/, and GYP joins its output paths against that depth. A dependency reference pointing outside the package directory — which any non-flat node_modules produces — then resolves one level short of its target. The build either fails with a Python FileNotFoundError naming a .target.mk path, or writes a duplicate tree next to the intended one.
Install that project flat:
nub install --node-linker hoistedThe limitation is GYP's, not the linker's: GYP assumes a single source tree rooted at --depth, and the same builds fail the same way under pnpm's virtual store. A package that reaches a dependency's headers through include_dirs instead is unaffected — that path is a compiler flag and never reaches the computation.
Yarn
Yarn is supported read-only — Nub reads the Yarn lockfile (Classic v1 and Berry v2+) to install and run a project, but never writes it.
Node managernub node
Manage the Node versions Nub provisions — pin a version and it's fetched automatically, or drive the cache explicitly with the install, list, uninstall, and pin subcommands.