How to Diagnose and Fix Slow Shell Startup
If you use iTerm or any terminal emulator, you might have noticed it takes 2-3 seconds before the cursor appears. It was fine at first, but after installing various tools over time, shell startup becomes noticeably sluggish. This is almost always caused by heavy initialization scripts in your shell config files (.zshrc, .zprofile, etc.).
Why It Gets Slow
When you open a terminal, zsh reads config files in this order:
.zshenv— runs for every zsh session, first.zprofile— runs for login shells.zshrc— runs for interactive shells.zlogin— runs for login shells, after.zshrc
Each initialization script in these files adds up. The usual suspects are:
conda init — spawns a Python process every time to generate shell hook code. The conda init zsh command auto-inserts this block into .zshrc, and it alone can eat 1+ seconds.
NVM (Node Version Manager) — sourcing nvm.sh is heavy. It typically takes 0.5-0.7 seconds. Sometimes the same NVM code exists in both .zshenv and .zshrc, doubling the load time.
oh-my-zsh + plugins — oh-my-zsh itself isn't that heavy, but stacking multiple plugins adds up. Git-related plugins and gitstatus in particular can be slow.
rbenv, pyenv, and other version managers — eval "$(rbenv init -)" runs on every shell start.
brew shellenv — sets up Homebrew paths. Takes about 0.2-0.3 seconds.
Cloud SDKs (gcloud, aws-cli, etc.) — completion scripts are heavier than you'd expect.
Measuring Startup Time
If you know it's slow but don't know what's causing it, measure first.
Total Startup Time
/usr/bin/time zsh -i -c exit
This launches an interactive zsh session and immediately exits, showing elapsed time. Compare it against a bare session with no config:
/usr/bin/time zsh --no-rcs -i -c exit
The bare session usually finishes in under 0.01 seconds. The difference is entirely from your config files.
Per-Component Timing
To pinpoint which initialization is slow, time each one individually:
# NVM loading time
/usr/bin/time zsh -c 'source "$NVM_DIR/nvm.sh"' 2>&1
# conda init time
/usr/bin/time zsh -c 'eval "$(/opt/anaconda3/bin/conda shell.zsh hook)"' 2>&1
# brew shellenv time
/usr/bin/time zsh -c 'eval "$(/opt/homebrew/bin/brew shellenv)"' 2>&1
# oh-my-zsh loading time
/usr/bin/time zsh -c 'source $ZSH/oh-my-zsh.sh' 2>&1
zsh Profiling
For a more detailed breakdown, add profiling code to your .zshrc:
# Add to the very top of .zshrc
zmodload zsh/zprof
# Add to the very bottom of .zshrc
zprof
Opening a new terminal will print detailed profiling output showing which functions took how long. Remove this code once you're done investigating.
How to Fix It
1. Remove Duplicate Loading
First, check if the same initialization code exists in multiple files. NVM and conda code often appears in both .zshenv and .zshrc. Since .zshenv runs for all zsh sessions (including non-interactive ones), code that's only needed for interactive shells should live exclusively in .zshrc.
# If NVM code exists in .zshenv, remove it from there.
# Keep it only in .zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
2. Apply Lazy Loading
This is the most impactful optimization. Instead of running initialization on every shell start, defer it until the command is actually used for the first time.
NVM Lazy Loading
# Instead of the standard NVM initialization:
# export NVM_DIR="$HOME/.nvm"
# [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Use lazy loading:
export NVM_DIR="$HOME/.nvm"
nvm() {
unfunction nvm node npm npx
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm "$@"
}
node() {
unfunction nvm node npm npx
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
node "$@"
}
npm() {
unfunction nvm node npm npx
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npm "$@"
}
npx() {
unfunction nvm node npm npx
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
npx "$@"
}
The first time any of nvm, node, npm, or npx is called, it loads nvm.sh and then runs the command. There's a slight delay on the very first invocation, but after that everything works normally.
Conda Lazy Loading
# Instead of the standard conda init block:
# __conda_setup="$('/opt/anaconda3/bin/conda' 'shell.zsh' 'hook' 2> /dev/null)"
# ...
# Use lazy loading:
conda() {
unfunction conda
__conda_setup="$('/opt/anaconda3/bin/conda' 'shell.zsh' 'hook' 2> /dev/null)"
if [ $? -eq 0 ]; then
eval "$__conda_setup"
else
if [ -f "/opt/anaconda3/etc/profile.d/conda.sh" ]; then
. "/opt/anaconda3/etc/profile.d/conda.sh"
else
export PATH="/opt/anaconda3/bin:$PATH"
fi
fi
unset __conda_setup
conda "$@"
}
This alone saves 1+ seconds from shell startup since conda's Python-based hook generation only runs when you actually use conda.
rbenv Lazy Loading
# Instead of:
# eval "$(rbenv init - zsh)"
# Use lazy loading:
rbenv() {
unfunction rbenv
eval "$(command rbenv init - zsh)"
rbenv "$@"
}
3. Trim oh-my-zsh Plugins
Check the plugins=(...) line in your .zshrc and remove anything you don't actively use.
# Too many plugins slow things down
plugins=(git nvm docker kubectl aws terraform golang rust python)
# Keep only what you actually use
plugins=(git docker)
If you don't need oh-my-zsh at all, removing it and manually setting up just zsh-syntax-highlighting and zsh-autosuggestions is often enough.
4. Lighten Completion Scripts
If gcloud or aws-cli completion scripts are heavy, consider loading them on demand or skipping them entirely.
# If gcloud completion takes 0.5+ seconds,
# just add the binary to PATH without completion
export PATH="$HOME/google-cloud-sdk/bin:$PATH"
# Load completion manually when needed
gcloud-init() {
source "$HOME/google-cloud-sdk/completion.zsh.inc"
}
Before and After
Here's an actual measurement after applying lazy loading to conda and NVM:
Before: 2.23 real 1.17 user 0.88 sys
After: 0.55 real 0.31 user 0.22 sys
Just those two changes cut over 1.5 seconds. If you open terminals frequently, this difference is very noticeable.
Summary
A slow shell is almost always caused by .zshrc. Measure it, and you'll find that version managers (conda, NVM, rbenv) and Cloud SDK init scripts account for most of the time.
- Remove duplicate initialization across config files
- Apply lazy loading for tools you don't use on every session
- Keep only the oh-my-zsh plugins you actually need