nvm, pyenv, and yarn are silently slowing down your AI coding tools
I wrote this post with AI (edited) based on the output of a Claude Code session where I prompted Claude: “my shell is starting slow. help me fix it”.
I noticed that Claude Code was slow to create a git commit, taking ~5 seconds to run git status, git diff --stat, git commit. These commands should be fast. The culprit was slow shell startup time. My non-interactive zsh took 1 second to start. Interactive was 1.9 seconds. That’s before any command runs. I asked Claude Code to analyze my shell startup. It found that nvm, pyenv, yarn, and brew shell inits were taking ~2 seconds with every shell startup.
Below I’ll show how to profile your shell startup, what each of these tools costs, and the specific fix for each. I got my shell init from 2 seconds down to 15ms. Or skip the post entirely and tell Claude Code “my shell starts slow, time it, fix it” — that’s how I started this session, and it profiled and fixed everything described below.
Diagnosing the problem#
You can time your shell startup directly:
# Non-interactive (what tools like Claude Code spawn)
time zsh -c 'true'
# Interactive
time zsh -i -c 'exit'
My non-interactive shell took ~1 second; interactive was 1.9 seconds. The Bash tool in Claude Code uses non-interactive shells, so .zshenv is the file that matters — but .zshrc loads for interactive use and many of the same tools appear in both. To find what’s slow, time each init in isolation:
time (source /opt/homebrew/opt/nvm/nvm.sh 2>/dev/null) 2>&1
time (eval "$(/opt/homebrew/bin/brew shellenv)") 2>&1
time (eval "$(pyenv init --path)") 2>&1
time (yarn global bin 2>/dev/null) 2>&1
In my case, nvm and yarn were the worst offenders:
| Component | Time |
|---|---|
| nvm (+ .nvmrc auto-switching) | ~1.7s |
| nvm (just sourcing) | 0.63s |
| yarn global bin | 0.46s |
| oh-my-zsh | 0.37s |
| direnv hook | 0.27s |
| pyenv init (×2) | 0.47s total |
| brew shellenv | 0.05s |
eval "$(tool init)" subprocesses are the problem. Especially for tools
written in node or Python with slower startup times. Each one forks a process,
runs a program, captures its output, and evals it — just to set a few
environment variables and PATH entries.
The fixes#
nvm → fnm (~1.7s)#
nvm is the worst offender. It’s a shell script that takes 600ms+ just to load, and if you also auto-switch node versions based on .nvmrc files (a common setup), that adds another second on top. fnm is a drop-in replacement written in Rust. It supports .nvmrc files, and fnm env runs in ~14ms.
brew install fnm
Replace your nvm block with:
# .zshenv
eval "$(fnm env --shell zsh)"
# .zshrc (for interactive .nvmrc auto-switching)
eval "$(fnm env --use-on-cd --shell zsh)"
yarn global bin → static path (~0.46s)#
yarn global bin spawns a Node process to tell you where yarn puts global binaries. It’s always $HOME/.yarn/bin:
# Replace: export PATH="$(yarn global bin 2>/dev/null):$PATH"
# With:
export PATH="$HOME/.yarn/bin:$PATH"
pyenv init → hardcoded shims PATH (~0.24s)#
pyenv init --path spawns a bash subprocess and runs pyenv rehash on every shell start to rebuild shim files in ~/.pyenv/shims/. But pyenv already does this automatically after pyenv install — the --path output just adds the shims directory to PATH:
# Replace: eval "$(pyenv init --path)"
# With:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/shims:$PATH"
If you have pyenv init --path in .zshenv and pyenv init - in .zshrc, you’re also paying twice. Keep the PATH setup in .zshenv and drop the .zshrc one unless you need pyenv’s shell functions interactively.
brew shellenv → static exports (~0.05s)#
brew shellenv runs /usr/libexec/path_helper internally, which reads /etc/paths.d/* to reconstruct the system PATH. The system PATH is already set before your shell starts (via /etc/zprofile), so this is redundant. The output is always the same:
# Replace: eval "$(/opt/homebrew/bin/brew shellenv)"
# With:
export HOMEBREW_PREFIX="/opt/homebrew"
export HOMEBREW_CELLAR="/opt/homebrew/Cellar"
export HOMEBREW_REPOSITORY="/opt/homebrew"
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
export MANPATH="/opt/homebrew/share/man:${MANPATH:-}"
export INFOPATH="/opt/homebrew/share/info:${INFOPATH:-}"
These values only change if you move your Homebrew installation.
Duplicate sourcing#
Check if you’re sourcing the same thing in both .zshenv and .zshrc — I had cargo env and pyenv init in both. .zshenv runs for every shell invocation including interactive ones, so anything there doesn’t need to be repeated in .zshrc.
Results#
After applying these changes, I measured the Bash tool round-trip time using timestamps from Claude Code’s session transcript:
| Bash tool round-trip | |
|---|---|
| Before | 964ms |
| After | 65ms |
Non-interactive shell startup (time zsh -c 'true') went from 1 second to 15ms. For a commit operation that runs 4-5 commands, that’s roughly 5 seconds saved — time that was previously spent loading nvm, running pyenv rehash, and spawning yarn just to set PATH variables. All of these changes were profiled and applied by Claude Code in a single session. If you don’t want to do this manually, you can paste the diagnostics section above into Claude Code and ask it to fix what it finds.