~/blog/driftr-volta-replacement

Driftr: What I Built When Volta Stopped Moving

A shim-based Node.js toolchain manager in Go: syscall.Exec handoff, checksum-verified installs, nvm-compatible pins, and the pnpm shared store nobody turns on.

Krzysztof Słomka17 min readupdated

Driftr: a calm chevron logo at the center of a river of Node.js, pnpm, and Yarn icons

For years, Volta was the one tool in my Node.js setup I never thought about. I pinned a version per project, typed node, and the right binary ran. No nvm use, no shell hooks slowing down every prompt, no "wrong Node version" surprises in CI. It just worked, and it was fast.

Then one afternoon I went to check on something in the Volta repository and noticed it had gone quiet. Commits had thinned out, the roadmap had stalled, issues sat unanswered. The tool I trusted to sit at the center of my toolchain had stopped moving, and a version manager you can't trust to keep pace with new Node releases is a liability rather than a convenience.

I had two choices: wait and hope, or build the thing I actually wanted. I'd been sketching ideas for a leaner, shim-based manager for a while, so I started Driftr.

I first published this in July, when Driftr was at 0.11. It now describes 1.2, which reads the pin files you already have, manages bun, and adds driftr use, driftr outdated and driftr prune. Part 5 covers what changed.

Part 1: the itch#

A version manager should be invisible#

My whole philosophy for this kind of tool fits in a sentence: you should configure it once and never interact with it again.

That is not how most of them behave.

If you've used nvm, you know the ritual. You cd into a project, you remember (or forget) to run nvm use, and when you forget you spend ten minutes debugging a failure that was really just the wrong Node version. People work around it with shell hooks that auto-switch on directory change, which means a function now runs on every prompt, parsing .nvmrc files, and your shell startup gets slower. On a cold terminal you can feel it.

fnm is a faster, Rust-based take on the same idea, and it's good. But it's the same model: a shell hook that rewrites your PATH as you move around the filesystem.

Volta went a different way, which is why I liked it. It used shims. Instead of rewriting your PATH on every directory change, it put small stand-in executables on your PATH once. When you ran node, you were really running Volta's shim, which worked out the right version and handed off. No per-prompt cost, no "did I forget to switch?" The version was just correct everywhere.

That is the right model. When Volta stalled, I didn't want a different model. I wanted the same one, maintained, and pushed in the directions I needed.

The three models compared: shell hooks (nvm), PATH rewrite (fnm), and shims (Volta / Driftr)

When a version manager stops being maintained#

It's tempting to shrug at an unmaintained tool. It still works, doesn't it? For a lot of libraries, sure, for a while. For a version manager, not really.

The job is to keep up with a moving target. Node ships new majors. Release index formats change. Checksums and signing change. Platforms move, and Apple Silicon was a recent reminder of how fast "every tool needs an arm64 build" becomes table stakes. A version manager that isn't maintained slowly rots: new Node versions stop resolving, a platform you need isn't supported, a fix in the download path never lands.

Volta's maintainers have since said it themselves. The README now opens with "Volta is unmaintained" and recommends migrating to mise.

So this wasn't really about preference. The tool at the center of my setup had stopped moving, which meant it was time for me to.

Part 2: what Driftr is#

Driftr is a shim-based version manager for the Node.js toolchain (node, npm, npx, pnpm, pnpx, yarn, bun), written in Go. You install versions, pin them per project, and run your tools normally. The shims resolve the right binary for you.

The everyday surface is deliberately small:

# In a repo that already pins its tools, install whatever it asks for
driftr install

# Or install by name. A bare tool name installs the latest release.
driftr install node@lts
driftr install pnpm@9
driftr install bun@1

# Pin a version to the current project
driftr pin node@24

# See what would actually run, and why
driftr which node

# Then just use your tools. The shims resolve the pin.
node -v      # v24.x.x, because this project is pinned
pnpm install # uses the pinned pnpm

The pin lives in .driftr.toml:

[tools]
node = "24.16.0"
pnpm = "9.15.0"

Or, if you'd rather keep everything in one place, in the driftr key of package.json:

{
  "name": "my-app",
  "driftr": { "node": "24.16.0" }
}

You don't have to adopt either. If the project already has an .nvmrc, a .node-version, an engines.node range or a packageManager field, Driftr reads it as it is. A team can switch to Driftr without committing a single new file.

Outside a pinned project, Driftr falls back to a global default you set once. No shell hooks, no per-prompt cost. The version is correct wherever you are.

How resolution works#

When you type node, Driftr walks this chain top to bottom and takes the first match:

  1. an explicit override (used internally by driftr run)
  2. a per-shell override in DRIFTR_<TOOL>, set by driftr use
  3. the project's .driftr.toml
  4. the driftr key in package.json, then its packageManager field (pnpm, yarn and bun only)
  5. .nvmrc, then .node-version (node only)
  6. engines.node in package.json (node only)
  7. the global default

Every project-level source is searched upward from the current directory, so a nested config overrides its parent.

Partial versions resolve sensibly. node@24 means "the latest installed 24.x", node@lts the newest LTS release, and lts/jod in an .nvmrc means 22. You rarely type a full 24.16.0 unless you want to lock it exactly.

Resolution chain for node: explicit flag, DRIFTR_NODE, .driftr.toml, package.json driftr key, .nvmrc or .node-version, engines.node, then the global default

The first match wins and nothing after it is read. Everything added in 1.x slots in between the project config and the global default, so a repository that pins in .driftr.toml behaves exactly as it did in 0.11.

Part 3: why shims, and why Go#

Two decisions carry the whole tool, and they're the two that separate a manager you forget about from one you fight.

Shims, so there's no per-prompt cost#

A shim is a tiny executable named exactly like the tool it stands in for. ~/.driftr/bin/node sits on your PATH. Run node and you're running that shim, which asks the resolver which version this directory wants and then hands off to the real binary.

The important property is that it costs nothing when you're not running a tool. No shell function fires on every prompt, no .nvmrc parsing as you move around, no PATH rewriting on directory change. Your shell starts exactly as fast as it did before you installed Driftr. You pay only when you run a tool, which is the only time the work is needed.

Go and syscall.Exec, so the handoff costs a few milliseconds#

The shim runs in front of every command, so its hot path matters. The naive version launches the real Node as a child process and waits, which leaves a wrapper sitting in memory for the life of your command, with its own PID and a few megabytes of overhead, complicating signal handling.

Driftr doesn't do that. Once it resolves the version, it calls syscall.Exec, which replaces the shim's own process image with the real Node binary. No child, no wrapper hanging around. The shim becomes Node. By the time your code runs, the thing in that PID slot is just node, as if Driftr was never there. On an Apple Silicon Mac, the measured gap between running ~/.driftr/bin/node -v and calling the real binary is about 5 ms. That is per tool call. Opening a shell costs nothing.

That is what makes it feel invisible. You aren't paying a tax for version management, you're paying a rounding error.

exec

resolved version

resolved version

resolved version

resolved version

resolved version

resolved version

~/.driftr/bin/node
(shim)

driftr shim node $@

resolver.ResolveBinary(node)

walk config chain

DRIFTR_NODE
(driftr use)

.driftr.toml

package.json driftr key

.nvmrc / .node-version

engines.node

global default

syscall.Exec(realNode, args, env)
process replaced, zero overhead

The process handoff: the shim's process image is replaced by the real node binary via syscall.Exec

Reliability#

A tool that sits in front of every command has to be trustworthy in unglamorous ways.

Every download is verified before Driftr unpacks it: Node.js and bun against their published SHA-256 sums, pnpm and yarn against the SHA-512 integrity hash from the npm registry. A checksum mismatch deletes the cached archive so the next attempt re-downloads cleanly. If an extraction fails or is interrupted partway, Driftr removes the half-written version directory so it can never be mistaken for a good install later. Archive entries are path-cleaned, and a symlink pointing outside the install directory is rejected rather than followed. Every user-facing error ends with the exact command that fixes it, like Run 'driftr install node@24.1.0', instead of leaving you to guess.

And the dependency list stays at two libraries: Cobra for the CLI and a TOML parser. The Go standard library covers HTTP, SHA-256, tar, and syscall. A tool this central has no business dragging in a large dependency tree.

None of that is exciting. All of it is the difference between a tool you trust and one you don't.

Part 4: Driftr stops pretending to be a package manager#

This is where Driftr stops being "a maintained Volta" and becomes its own thing.

The problem isn't the Node version. It's node_modules.#

The wrong Node version is an annoyance. The real weight on a working machine is node_modules.

Picture a normal setup: twenty, thirty, fifty Node projects on disk, each with its own multi-gigabyte dependency tree. Most of those gigabytes are the same packages copied over and over: the same react, the same typescript, the same lodash, duplicated across projects you haven't opened in months. It is completely normal for a home directory to be carrying tens of gigabytes of redundant dependencies.

A laptop buried under duplicated node_modules boxes

pnpm already solved this. Almost nobody turns it on.#

The frustrating part is that the fix already exists, in a tool millions of developers use.

pnpm stores each package once in a global, content-addressable store and hard-links it into every project's node_modules. Install the same react in fifty projects and it lives on disk once. The savings are large, often the difference between something like 68 GB of duplicated trees and an 11 GB shared store.

The catch is that most developers either don't know the feature exists or never configure it. It's sitting right there, switched off, in a tool they already have. Huge value, almost no adoption, all because of a setup step nobody takes.

So Driftr does it for you#

The biggest feature in Driftr is a Node optimization module. It doesn't replace pnpm, which is excellent. It makes pnpm do the thing it's already good at by handling the configuration you were never going to do by hand.

Four commands:

driftr node doctor     # analyze the current environment
driftr node optimize   # configure pnpm's shared store
driftr node clean      # reclaim space safely
driftr node report     # see project usage vs. shared store

driftr node doctor inspects the project and reports back in plain language: which package manager it uses, whether Corepack is on, the size of the local node_modules, where the pnpm store lives, and whether the global virtual store is enabled. Then it suggests a next step.

Driftr Node Doctor

Project:              ~/projects/api
Package manager:      pnpm
Node.js:             24.16.0
pnpm:                9.15.0
node_modules:        1.8 GB
pnpm store:          ~/.driftr/stores/pnpm
global virtual store: disabled

Recommendation
Enable pnpm shared storage to reduce duplicated dependency data.
Run: driftr node optimize

driftr node optimize makes those changes: it enables Corepack, points pnpm at a shared store under Driftr's control, and turns on the global virtual store so dependency data is shared across every project on the machine. It's idempotent, so anything already correct is left alone, and it prints exactly what it changed.

Optimizing pnpm shared dependency storage

corepack enabled
set store-dir = ~/.driftr/stores/pnpm
set enable-global-virtual-store = true

Done. Dependencies are now stored in the shared pnpm store.

driftr node clean is destructive by nature, so it is a dry run by default. It shows what it would remove and prune, and only touches anything when you pass --yes. The safe path is the default.

Dry run
No changes made. Re-run with --yes to execute.

Would remove: ~/projects/api/node_modules (1.8 GB)
Would run:    pnpm install
Would run:    pnpm store prune

driftr node report gives you the honest numbers: this project's node_modules against the shared store that's reused across everything. It won't invent a "you saved 57 GB" figure for a single project, because the store's value is spread across the whole machine, and the report says so.

After optimization: many projects hard-linked to one shared content-addressable pnpm store

Why the framing matters#

I'm deliberate about the positioning, because it's the whole point of the tool. Driftr isn't trying to be another package manager. It's an environment manager that automates the good practices the better package managers already support but rarely have switched on.

That is a smaller and more honest job than "replace pnpm", and it still cuts disk usage and install times across the machine without asking you to change how you work.

Part 5: what 1.x added#

Driftr went from 0.11 to 1.2 between July and September. Most of that work went into one idea: Driftr should adopt a project as it is, not ask the project to adopt Driftr.

Your existing pins already work#

A repository that has never heard of Driftr usually pins its Node version somewhere. Driftr now reads all the usual places:

  • .nvmrc and .node-version, including lts, lts/* and codename aliases such as lts/jod
  • engines.node, where Driftr takes the newest installed version that satisfies the range
  • packageManager in package.json, for pnpm, yarn and bun

None of these reach the network. The shim runs on every node call, so LTS aliases and ranges resolve against what you have installed. When nothing installed fits, you get the usual error with the install command to run. The range syntax is deliberately partial. Hyphen ranges like 18 - 20 and pre-release tags get an error naming the range, because a guess here would pick the wrong Node silently.

In practice, onboarding a cloned repository is now one command:

git clone git@github.com:acme/api.git && cd api
driftr install   # installs whatever the project pins, from any of the sources above
node -v          # already the right one

driftr install takes no argument here. It walks the same resolution chain the shims use and installs whatever comes back missing.

bun is a managed tool#

bun joined node, pnpm and yarn as a first-class tool: driftr install bun@1, a bun shim, a pin in .driftr.toml or packageManager, and releases listed from bun's GitHub. It is a standalone binary, so the shim hands off to it with syscall.Exec the same way it does for node.

Three new commands#

driftr use pins a version for one shell and nothing else. It prints a snippet that sets DRIFTR_NODE, which sits above every config file in the chain:

eval "$(driftr use node@24)"
node -v   # 24.x in this shell only; the project pin is untouched

That is the one case where I wanted nvm's per-shell switching back: trying a project on the next major before anyone commits a pin. Close the shell and it's gone.

driftr outdated compares what each tool resolves to against upstream:

TOOL  CURRENT  SOURCE                          LINE LATEST  LATEST   LTS      STATUS
node  22.14.0  project config                  22.21.0      24.11.0  22.21.0  behind: 22.21.0 in 22.x, latest 24.11.0
pnpm  9.0.0    project config (not installed)  9.15.9       10.4.0   -        newest in 9.x, newer major: 10.4.0

LINE LATEST is the column to read first. It is the newest release on the major you already run, which is usually the upgrade you can take today without reading a changelog. With --exit-code, the command exits 1 when something is behind, so it doubles as a CI check.

driftr prune removes installed versions that neither the global default nor the current project references, with --dry-run to see the plan first. It has one limit worth knowing: it does not scan the machine for other checkouts. A version pinned only by a project in another directory looks unreferenced from here and will be offered for removal. Run it from the project you care about.

What 1.x does not do#

It still doesn't run on Windows. If you need Windows, fnm or mise will serve you better. The driftr node module still only automates pnpm; npm and yarn projects get a diagnosis from driftr node doctor, not an optimization.

Part 6: how Driftr compares#

Every one of these tools is good. They optimize for different things.

nvmfnmVoltamiseDriftr
Mechanismshell functionshell hookshimsshell hook (shims optional)shims
Cost paidat shell startupper prompt / directory changeper tool callper prompt / directory changeper tool call
LanguageshellRustRustRustGo
Tools managednodenodenode, npm, yarn; pnpm experimentalnode, pnpm, yarn, bun and hundreds morenode, npm, npx, pnpm, yarn, bun
Reads .nvmrc / .node-versionyesyesnoyesyes
Reads packageManagernovia Corepacknovia Corepackyes
Checksum-verified installsyesnonoopt-in (mise.lock)always
Dependency-storage optimizationnonononoyes (driftr node)
Windowsnoyesyesyesno
Actively maintainedyesyesnoyesyes

I checked every row against each project's repository and documentation in September 2026. The row I care about most is dependency-storage optimization. It is the thing none of the others do, because none of them think of themselves as environment managers. Driftr does.

If you want one tool for Node, Python, Ruby and everything else, mise is the better answer, and Volta's own README says as much. Driftr is for people who want Volta's model for the JavaScript toolchain and nothing more.

Part 7: getting it#

Driftr is open source and ships regularly. Install it through Homebrew or the install script:

# Homebrew (macOS / Linux)
brew tap StackMade/driftr
brew install driftr
driftr setup

# or the install script
curl -fsSL https://raw.githubusercontent.com/StackMade/driftr/main/install.sh | sh

driftr setup generates the shims and configures your shell PATH for you. Then driftr doctor confirms everything is wired correctly, and driftr doctor --fix repairs the PATH and shims when it isn't. driftr self-update keeps the binary current.

It runs on macOS and Linux, on x64 and arm64. Releases are checksummed, and the checksum file carries a keyless Sigstore signature you can check with cosign verify-blob. As promised, the dependency footprint stays minimal, because a tool that runs in front of every command should be light enough that you forget it's there.

A quiet desk at dusk, terminal showing a single green checkmark

Closing#

I built Driftr because the tool I relied on stopped moving. The nice surprise was that rebuilding it let me add the things I'd always wished a version manager did, starting with the dependency-storage problem that every Node developer has and almost nobody configures.

If you're a Volta user who noticed the same quiet I did, an nvm user tired of the shell-hook cost, or someone whose laptop is quietly drowning in duplicated node_modules, that's who I built it for.

Clone a repository, run driftr install, let it sort out your pnpm store, and then forget about all of it.

The repository is at github.com/StackMade/Driftr. I'd like to hear what you'd want a version manager to stop making you think about next.

StackMade/DriftrFast JavaScript toolchain versioning without the friction. A lightweight JavaScript toolchain manager built for speed and simplicity. The spiritual successor to Volta, made for developers by developers.Go★ 2⑂ 0view on GitHub →

$ related posts