nvm, pyenv, and yarn are slowing down your AI coding harness

5 min read Original article ↗

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.

Churning… (0s)

Vibing… (0s)

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.

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.