· 12 min read

Driftr: What I Built When Volta Stopped Moving

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.

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.

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), 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:

# Install a version. A bare tool name installs the latest release.
driftr install node
driftr install pnpm@9
driftr install yarn@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" }
}

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. the project's .driftr.toml
  3. the driftr key in package.json
  4. the global default

Partial versions resolve sensibly. node@24 means "the latest installed 24.x", and node@latest resolves against the official release index. You rarely type a full 24.16.0 unless you want to lock it exactly.

Resolution chain: explicit flag, .driftr.toml, package.json driftr key, then the global default

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 actually run a tool, which is the only time the work is needed.

Go and syscall.Exec, so the handoff is about a millisecond

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. The overhead is on the order of a millisecond.

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

~/.driftr/bin/node
(shim)

driftr shim node $@

resolver.ResolveBinary(node)

walk config chain

.driftr.toml

package.json driftr key

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 checked against the official SHASUMS256.txt before Driftr trusts it, and a checksum mismatch deletes the cached archive so the next attempt re-downloads cleanly. If an extraction fails partway, Driftr removes the half-written version directory so it can never be mistaken for a good install later. 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 tiny: Cobra for the CLI, a TOML parser, and the Go standard library for 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 newest big 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 actually moves the needle on disk usage, install times, and machine-wide consistency without asking you to change how you work.

Part 5: how Driftr compares

A quick, honest lay of the land. Every one of these tools is good; they just optimize for different things.

nvmfnmVoltaDriftr
Mechanismshell hookshell hook / PATHshimsshims
Per-prompt overheadyessmallnonenone
LanguageshellRustRustGo
Whole toolchain (npm/pnpm/yarn)nonoyesyes
Per-project pin file.nvmrc.node-versionpackage.json.driftr.toml or package.json
Checksum-verified installspartialyesyesyes
Dependency-storage optimizationnononoyes (driftr node)
Actively maintainedyesyesstalledyes

The row I care about most is the second from the bottom. Dependency-storage optimization is the thing none of the others do, because none of them think of themselves as environment managers. Driftr does.

Part 6: getting it

Driftr is open source and ships regularly. Installing is a one-liner through Homebrew or the install script:

# Homebrew (macOS / Linux)
brew install driftrlabs/driftr/driftr
driftr setup

# or the install script
curl -fsSL https://driftr.dev/install.sh | sh

driftr setup generates the shims and configures your shell PATH for you. Then driftr doctor confirms everything is wired correctly.

It runs on macOS and Linux, on x64 and arm64. Releases are checksummed and signed with Sigstore. 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 — it just works

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.

Pin a version, run node, let it sort out your pnpm store, and then forget about all of it.

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

DriftrLabs/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.Go20view on GitHub →