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 fall out of this layout:
- 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 a sealed world. 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 deep dive on that history and the mechanism below is 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. Two 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, and bare React Native, whose Metro config crawls by real path — 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 pays: relinking costs O(packages) while every other installer pays O(files), so the gap widens as trees get fatter.
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: 4.2× faster than Nub's own hoisted mode, 5.5× faster than Bun. On a thinner 313-package tree the default's lead over Bun is 1.7× — the advantage grows with the tree.
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, so you rarely set it by hand. 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 0 files (0.0 MB) from the store
Pruned 2 entries from the virtual store and 0 entries from the extracted-tree tierNub records every project that installs against the shared store. A prune keeps the entries those projects still reach — directly, or through another entry's own dependencies — and removes the rest.
A registered project that nub cannot find might be deleted, or might be on a disk that is not mounted, and those look identical. It stops counting as a store user and the prune says so, but everything else still gets collected. Whatever only that project reached becomes unreferenced and takes the usual 30-day hold, so plugging the disk back in before then keeps it.
Removal takes two passes. The first prune to find an entry unreferenced holds it and says so; 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 24 entries 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 exactly like garbage. Without the hold, one install followed by one prune would delete the rest.
With nothing registered at all, a prune skips the store entirely rather than treating an empty registry as an empty store:
No projects are registered against the virtual store; skipping it.
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. Opening each project you care about and running an install once is enough.
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, sits 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 dies 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. Treat a Yarn project as something Nub consumes, not maintains.
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.