I maintain a symlink-based dotfiles repo for macOS/zsh. Over time, I'd accumulated three different approaches to managing runtime versions: nvm for Node.js, brew install go for Go, and brew install python@3.13 for Python. Each had its own quirks, its own update ritual, and its own startup cost. mise consolidates all of them into one tool with a single config file.
This article walks through the actual migration: what changed, what broke, and the non-obvious tricks that made it worth doing.
The starting point: an nvm lazy-load hack
nvm is slow. Loading it adds 300-500ms to every new shell. My workaround was a lazy-load wrapper in zprofile that deferred the cost until you actually called node, npm, or friends:
# NVM (lazy-loaded for fast startup)
export NVM_DIR="$HOME/.nvm"
_nvm_lazy_load() {
unfunction nvm node npm npx corepack 2>/dev/null
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
}
nvm() { _nvm_lazy_load; nvm "$@" }
node() { _nvm_lazy_load; node "$@" }
npm() { _nvm_lazy_load; npm "$@" }
npx() { _nvm_lazy_load; npx "$@" }
corepack() { _nvm_lazy_load; corepack "$@" }
This worked, but it was 15 lines of shell plumbing to avoid a startup penalty from a tool that only manages Node. Go and Python had no version management at all — I just installed whatever Homebrew gave me.
The mise config
mise replaces all of that with a TOML file:
[tools]
node = "22"
go = "1.24"
python = "3.13"
[settings]
idiomatic_version_file = true
That's it. mise install downloads the right versions. The idiomatic_version_file setting means mise reads .nvmrc, .node-version, .python-version, and .go-version files from existing projects — so teams using nvm or pyenv don't need to change anything.
Shell integration: the dual activation trick
This is the part that took some thought. mise offers two modes:
Shims (
mise activate zsh --shims): Lightweight stubs in~/.local/share/mise/shims/that resolve to the right binary. Fast, works everywhere, but they don't auto-switch versions when youcdinto a project with an.nvmrc.Activate (
mise activate zsh): Hooks into your shell'sprecmd/chpwdto dynamically updatePATH. Supports auto-switching, but doesn't run in non-interactive contexts (scripts, IDE terminals running commands, cron jobs).
You need both.
In zprofile (runs for all sessions, including non-interactive):
# Mise — shims for non-interactive sessions (scripts, IDEs, cron)
eval "$(mise activate zsh --shims)"
In plugins.zsh (sourced only in interactive shells via zshrc):
_cache_source mise mise activate zsh
The activate call in interactive shells overrides the shims with real PATH entries, enabling directory-aware version switching. Non-interactive sessions fall back to shims, which resolve to whatever version the config specifies.
The _cache_source + zcompile trick
That _cache_source call above is doing more than it looks. Running mise activate zsh generates a shell script on every new shell — and that generation adds 25-300ms depending on the machine. For a shell that opens in 50ms total, that's unacceptable.
The solution: cache the output and compile it to zsh bytecode.
_cache_source() {
local name="$1"; shift
local cache_file="$ZSH_COMP_CACHE/$name.zsh"
local -a stale=($cache_file(N.mh+24))
if [[ ! -f "$cache_file" ]] || (( $#stale )); then
"$@" > "$cache_file" 2>/dev/null
zcompile "$cache_file" 2>/dev/null
fi
source "$cache_file"
}
Here's what it does:
- Runs
mise activate zshand captures the output to a cache file - Compiles it to zsh bytecode with
zcompile(creates a.zwcfile) - Sources the cached file on every subsequent shell open
- Regenerates after 24 hours (the
(N.mh+24)glob qualifier checks mtime)
Why does the compiled version load faster? When you source a plain .zsh file, zsh reads the text, tokenizes the shell syntax, and parses it into an internal representation — every single time. zcompile does that work once and writes the result as a .zwc file — a pre-parsed "wordcode" format that zsh can memory-map and load directly, skipping the tokenize-and-parse steps entirely. For a large generated script like mise activate output, this eliminates the most expensive part of source.
The clever bit is that zsh handles .zwc lookup transparently. When you run source foo.zsh, zsh first checks for foo.zsh.zwc. If the .zwc exists and is newer than the source file, zsh loads the compiled version without being told to. This means _cache_source doesn't need any special logic to prefer the compiled file — source "$cache_file" does the right thing on its own.
And if the .zwc is stale or missing? Zsh silently falls back to parsing the source file the normal way. Nothing breaks — you just lose the speed benefit until the cache regenerates. A stale .zwc can never serve incorrect code because zsh won't use it if the source has changed. This makes the whole approach safe to leave running unattended.
The result: mise activation costs <5ms instead of 25-300ms. This same function works for any "run a command, source the output" pattern — I use it for fzf, direnv, and mise.
This is probably the most reusable thing in the whole migration. If you maintain a zsh config and source any tool's init or activate output, wrap it in something like this.
For the full story on this caching approach — including a subtle glob qualifier bug that silently breaks it — see From 1.4s to 53ms: Optimizing zsh Startup on macOS.
Brew changes
The cleanup was straightforward:
Remove:
-
nvm— replaced by mise -
node— installed by mise -
go— installed by mise -
python@3.13— installed by mise -
yarn— moved to corepack (see below)
Added:
mise
One gotcha: some Homebrew formulae declare node as a dependency. In my case, markdownlint-cli2 depends on the Homebrew node formula. Removing node from the Brewfile triggers warnings on brew bundle, but it's cosmetic — mise's shims satisfy the actual runtime need. The formula's dependency declaration is about the build, not about your shell having node on PATH. I left it as-is and the warnings are harmless.
Corepack for yarn
With nvm gone, yarn no longer comes from Homebrew either. Node ships with corepack, which manages yarn (and pnpm) natively:
corepack enable
This creates symlinks inside the Node install directory. The catch: when mise installs a new Node version, those symlinks live in the old version's directory. You need to re-run corepack enable after mise install node@<new-version>. It's a one-liner, but easy to forget when you bump Node versions.
The migration procedure
The key insight is that nvm and mise can coexist. This makes the migration safe to do in phases:
Phase 1 — Install mise alongside nvm:
brew install mise- Create
mise/config.tomlwith your current versions - Add the dual activation (shims in
zprofile, activate inplugins.zsh) - Open a new shell, verify
node --version,go version,python3 --version
Phase 2 — Verify everything works:
- Run your builds, tests, dev servers
- Check IDE integration (VS Code, JetBrains)
- Verify non-interactive contexts:
zsh -c 'node --version' - Run
corepack enableand confirmyarn --version
Phase 3 — Remove nvm:
- Delete the lazy-load block from
zprofile - Remove
nvm,node,go,python@3.13,yarnfromBrewfile - Run
brew bundle cleanup --force - Commit
Safety net: nvm's data (~/.nvm/) stays untouched through all of this. If something breaks, you can restore the old zprofile block and you're back to where you started. The whole thing is one git checkout -- shell/zprofile away from reverting.
.nvmrc compatibility
If your team uses .nvmrc files (and most Node teams do), the idiomatic_version_file = true setting in mise's config handles it. When you cd into a directory with an .nvmrc, mise reads it and switches to the specified Node version — same as nvm would.
This also works for .node-version, .python-version, and .go-version. So mise supports the conventions from nvm, nodenv, pyenv, and goenv without any extra configuration.
Final result
The zprofile went from 30 lines to 18. The nvm lazy-load hack — 15 lines of shell functions — became a single eval line. Three separate version management approaches (nvm, Homebrew Go, Homebrew Python) became one config file.
Shell startup stayed at ~0.05s thanks to the caching strategy. The dual activation means shims handle non-interactive contexts while the full activate gives you directory-aware switching in your terminal.
The commit tells the story:
Replace nvm with mise for Node.js, Go, and Python version management
8 files changed, 17 insertions(+), 19 deletions(-)
More deletions than insertions. That's usually a good sign.
What else mise can do
This article focused on version management, but mise is a broader dev environment tool. Here's what else it offers once you have it installed.
Task runner
mise has a built-in task runner that replaces Makefiles and npm scripts. Tasks defined in mise.toml (or as standalone scripts in a mise-tasks/ directory) run with the full mise environment — correct tool versions and env vars already set. Dependencies between tasks execute in parallel by default, and mise watch re-runs tasks on file changes. For projects that already have a mise.toml for version management, adding tasks means one fewer tool in the chain.
Environment variables
The [env] section in mise.toml sets project-level environment variables that activate when you cd into the directory — the same behavior as direnv, without a separate tool. It supports dotenv files, required variables with validation, and redactions for secrets that shouldn't appear in logs. Earlier in this article, _cache_source caches direnv's activation output alongside mise's — mise's native env management could replace direnv entirely, removing one more tool from the shell startup chain.
Hooks
mise can run shell commands on directory enter/leave events and after tool installations. The postinstall hook is particularly relevant to this migration — it could automate the corepack enable step that's currently manual after Node upgrades.
Lockfile for reproducible environments
mise.lock pins exact tool versions and checksums per platform, similar to package-lock.json. For teams sharing a mise.toml, the lockfile ensures everyone gets the same binary — not just the same version range. Enable it with mise settings lockfile=true.
This article covered the version management migration, but mise's scope goes well beyond that — it's closer to a unified dev environment than a version manager.
The full dotfiles repo and migration commit are available on GitHub. The _cache_source function lives in shell/zshrc.d/completions.zsh if you want to steal it.
Top comments (0)