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.

Before
After
Churning… (0s)
Vibing… (0s)
(replay)

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.