← Back to the journal

Node Version Manager

Living with nvm. The .nvmrc setup, the "command not found" cases, and why your native modules broke.

nvm is a shell function, not a binary. Almost every strange thing it does follows from that one fact.

Install

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

Check the repo for the current version tag. The installer appends its setup lines to your shell profile, so re-running it with a newer tag is also how you upgrade.

The commands you actually use

nvm install --lts          # newest LTS
nvm install 22.11.0        # a specific version
nvm use 22                 # switch this shell to the newest installed 22.x
nvm ls                     # what's installed, and which is active
nvm ls-remote --lts        # what's available
nvm alias default 22       # what new shells start with
nvm current                # same answer as `node -v`, minus the guessing
nvm uninstall 18           # reclaim the disk space

Pin the version per project

Put the version in the repo so nobody has to ask:

echo "22" > .nvmrc

Then, in the project directory:

nvm use          # reads .nvmrc
nvm install      # reads .nvmrc and installs it if missing

To switch automatically on cd, nvm ships a ready-made hook for zsh and bash. The “Deeper Shell Integration” section of the README has the snippet to paste into your profile. Do it once and an entire category of “works on my machine” goes away.

nvm: command not found, in a place that isn’t your terminal

nvm defines a shell function, so anything that doesn’t load your interactive profile can’t see it: cron, systemd, CI, Makefiles, GUI editors, git hooks, sudo.

There are two fixes. Source nvm explicitly at the top of the script:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm use --silent

Or skip the shell entirely and call the version directly:

nvm exec 22 node build.js       # run one command under a given version
nvm which 22                    # absolute path to that node binary, for cron/PATH

Related: sudo npm install -g runs as root, which has its own PATH and its own system node. If you find yourself typing sudo with nvm installed, something is already wrong. nvm’s node lives in ~/.nvm and never needs it.

But I set the default version

nvm alias default applies to new shells. Terminal tabs that were already open, and processes started before the change, keep the old version. So does a VS Code integrated terminal spawned from a window opened earlier, so restart the editor rather than just the panel.

If node -v disagrees with nvm current, another node is winning the PATH race:

which -a node       # every node on PATH, in order

A Homebrew or .pkg install of node in /usr/local/bin or /opt/homebrew/bin sitting ahead of nvm’s shim is the usual culprit. Uninstall it (brew uninstall node) or make sure nvm’s line comes last in your profile.

Global packages disappeared after upgrading

Globals are installed per node version, so a new version starts empty. Carry them across at install time:

nvm install 22 --reinstall-packages-from=20

Don’t carry across npm itself (use --latest-npm instead), or anything with native bindings. Reinstall those so they compile against the new node.

NODE_MODULE_VERSION mismatch and ERR_DLOPEN_FAILED

Native modules (sqlite3, sharp, bcrypt, canvas, most Electron deps) are compiled against one specific node ABI. Switching node versions with an existing node_modules gives you an error that names two module versions and explains nothing:

npm rebuild                        # recompile against the current node
rm -rf node_modules && npm ci      # when rebuild isn't enough

Change node version, rebuild node_modules. Make that automatic in your head.

Slow shell startup

nvm’s profile snippet runs on every new shell and adds a noticeable pause. Time it:

time zsh -i -c exit

If nvm is the cost, lazy-load it: define a stub function for node, npm and npx that sources nvm on first use. There are one-screen snippets for this in the nvm README’s troubleshooting section, and it typically takes shell startup from around 500ms back to instant.

Things that catch people out

  • fish shell isn’t supported. Use nvm.fish, fnm, or asdf instead.
  • nvm-windows is a different project with a different CLI (nvm on / nvm off, no .nvmrc support). Commands here won’t all transfer.
  • On Apple Silicon, older node versions have no arm64 build, so nvm compiles from source. That’s slow, and needs Xcode command line tools. Anything from node 16 up ships a native arm64 binary.
  • nvm use inside a subshell doesn’t stick. (nvm use 18; node -v) affects only that subshell, and the same goes for nvm use in a script you executed rather than sourced.
  • Corepack, pnpm and yarn shims are per-version too. After switching node, run corepack enable again if pnpm vanishes.
  • Each version takes 50 to 200MB and they accumulate for years. Run nvm ls, then nvm uninstall the ones you’ll never touch again, and nvm cache clear for the download cache.