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. This page covers how the linker works, what it costs, and the opt-outs.
One symlink per package
Incumbent package managers build node_modules file by file. npm copies every file into every project; 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/…/[email protected]
└── react → ~/.local/share/nub/store/…/[email protected]Two 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:
# .npmrc — flat, npm-style node_modules for the whole project
node-linker=hoistedThe same for a single command, without touching config:
nub install --node-linker hoistedThis works under every incumbent — npm, pnpm, Yarn, Bun, and Nub-identity projects alike — because node-linker is a neutral key Nub reads from .npmrc regardless of which package manager owns the project. Files still materialize from the global content store by reflink or hardlink, so a flat tree costs little extra disk.
node-linker is the layout, not the 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. To flatten the tree, reach for node-linker — the hoist patterns leave the virtual store in place.
Per package manager
Each package manager has its own flat-layout config. Nub honors the ones that carry real intent, and falls back to the neutral node-linker key for the rest:
| Incumbent | Its own flat-layout config | Under Nub |
|---|---|---|
| pnpm | node-linker=hoisted in .npmrc, or nodeLinker: hoisted in pnpm-workspace.yaml | Honored — flat tree, no virtual store |
| npm | install-strategy=hoisted | Ignored — it is npm's default, so it carries no signal; set node-linker=hoisted |
| Yarn | nodeLinker: node-modules in .yarnrc.yml | Not read as a layout request; set node-linker=hoisted |
| Bun | none — Bun is always flat-materialized | Set node-linker=hoisted |
| Nub | node-linker=hoisted | Honored — flat tree, no virtual store |
Nub scopes package-manager compatibility to the lockfile and dependency resolution; the install layout is its own axis, defaulting to the virtual store. So an incumbent's layout config only turns the virtual store off where it is an explicit, unambiguous choice — pnpm's node-linker, set by hand. The neutral node-linker=hoisted is the one lever that works everywhere.
Flat vs project-local
Two settings turn off different halves of the default, for different reasons — pick by what you need:
# 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.
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.