Skip to main content

How I Used Claude Code to Speed Up My Shell Startup by 95%

7 min read

My terminal was sluggish. Every time I opened a new tab, there was this annoying delay before I could start typing. I decided to dig into it, and with Claude Code’s help, I went from a 770ms startup time to just 40ms. That’s a 19x improvement.

The Problem#

It wasn’t that I accumulated too much tooling. Most things I have in my .zshrc, I needed, but each thing I tacked on added to my shell startup time. I honestly hadn’t looked into this and just lived with it. Then, John Lindquist posted this the other day so I figured, let Claude Code speed it up for me.

Measuring the Baseline#

First thing I needed to know how bad it actually was:

Terminal window
for i in 1 2 3 4 5; do /usr/bin/time zsh -i -c exit 2>&1; done

Yikes:

Terminal window
0.94 real
0.71 real
0.74 real
0.73 real
0.72 real

Average: ~770ms per shell startup. Almost a full second just to open a terminal. Not good.

What Was Slowing Things Down#

Claude Code helped me identify the main culprits:

ToolImpactWhy It Sucks
nvm~300-500msLoads the entire Node.js environment every time
pyenv init~100-200msPython version management initialization
security command~50-100msFetching API key from macOS Keychain
brew shellenv~30-50msRuns a subshell to get Homebrew paths
gcloud completion~20-30msGoogle Cloud completions

The Big Unlock: Lazy Loading#

Most of the tools don’t need to be loaded until I actually use them. So defer the expensive stuff until the first time I run a command.

Self-Destructing Wrappers to the Rescue#

This is the clever part. Thanks Claude Code. The wrapper functions remove themselves after first use:

  1. First call: Wrapper runs, does the slow initialization, then deletes itself
  2. After that: Direct execution, zero overhead

You only pay the cost once per session.

nvm Lazy Loading#

Instead of this slow startup code:

Terminal window
# Before: runs every shell startup (~400ms)
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
nvm use default --silent

now, I use wrapper functions:

Terminal window
# After: only runs when you actually need node/npm/npx
nvm() {
unset -f nvm node npm npx
[ -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 "$@"
}
node() { nvm use default --silent; unfunction node npm npx; node "$@"; }
npm() { nvm use default --silent; unfunction node npm npx; npm "$@"; }
npx() { nvm use default --silent; unfunction node npm npx; npx "$@"; }

Update 2025-11-28: I scrapped nvm in favour of fnm, so I’ve since removed the nvm, node, npm and npx wrapper functions. If you do use nvm, keep them.

The unset -f and unfunction commands remove the wrapper after first use. After that, it’s like the tools were loaded normally.

pyenv Gets the Same Treatment#

Terminal window
# Before
eval "$(pyenv init -)"
# After
pyenv() {
unset -f pyenv
eval "$(command pyenv init -)"
pyenv "$@"
}

First pyenv call takes ~150ms to initialize, then it’s direct execution forever.

Google Cloud SDK#

Terminal window
gcloud() {
unset -f gcloud gsutil bq
[ -f "$HOME/.local/google-cloud-sdk/path.zsh.inc" ] && . "$HOME/.local/google-cloud-sdk/path.zsh.inc"
[ -f "$HOME/.local/google-cloud-sdk/completion.zsh.inc" ] && . "$HOME/.local/google-cloud-sdk/completion.zsh.inc"
gcloud "$@"
}
gsutil() { gcloud; gsutil "$@"; }
bq() { gcloud; bq "$@"; }

Homebrew Caching#

Homebrew’s environment doesn’t change often, so just cache it:

Terminal window
# Before: subshell every time
eval "$(/opt/homebrew/bin/brew shellenv)"
# After: cache to file, only regenerate if brew changes
if [[ ! -f ~/.zsh_brew_cache || ~/.zsh_brew_cache -ot /opt/homebrew/bin/brew ]]; then
/opt/homebrew/bin/brew shellenv > ~/.zsh_brew_cache
fi
source ~/.zsh_brew_cache

API Key Laziness#

I was hitting the macOS Keychain on every shell startup:

Terminal window
# Before: slow keychain lookup every time
export OPENAI_API_KEY=$(security find-generic-password -a $USER -s openai_api_key -w)

Since I only need this for npm stuff, I moved it into the npm wrapper:

Terminal window
npm() {
nvm use default --silent
unfunction node npm npx
[ -z "$OPENAI_API_KEY" ] && \
export OPENAI_API_KEY=$(security find-generic-password -a $USER -s openai_api_key -w)
npm "$@"
}

Update 2025-11-28: as mentioned above, I switched to fnm, so I changed this up since I removed the npm wrapper function. Now for my OpenAI API key, I just call a function whenever I need it instead:

Terminal window
# === Lazy-load OPENAI_API_KEY ===
openai_key() {
export OPENAI_API_KEY=$(security find-generic-password -a $USER -s openai_api_key -w)
}

More on the macOS Keychain tip in my newsletter.

Other Quick Wins#

I also cleaned up some basic stuff:

  • Combined 7 different PATH modifications into one line
  • Removed duplicate GPG_TTY exports
  • Fixed ordering so STARSHIP_CONFIG gets set before starship init

The Results#

After all the changes:

Terminal window
for i in 1 2 3 4 5; do /usr/bin/time zsh -i -c exit 2>&1; done
Terminal window
0.06 real
0.04 real
0.04 real
0.03 real
0.04 real

Average: ~40ms

BeforeAfterImprovement
770ms40ms95% faster

The “Trade-off”, Not Really#

Yeah, there’s a one-time cost when you first use each tool:

  • First node/npm/npx: +400ms
  • First pyenv: +150ms
  • First gcloud: +50ms

But it’s once per terminal session and honestly barely noticeable compared to what the commands actually do.

Try It#

If your shell is slow, first measure the total startup time:

Terminal window
time zsh -i -c exit

If it’s over 200ms, you’ve got room to improve. To see exactly what’s slow, profile your .zshrc or whatever shell you’re using:

Terminal window
# Add to top of .zshrc
zmodload zsh/zprof
# Add to bottom
zprof

This breaks down which specific commands are eating up your startup time.

My Updated Shell Configuration File#

Here’s my updated shell with all the performance tweaks.

Terminal window
# === Early exports (no subshells) ===
export HOMEBREW_NO_AUTO_UPDATE=1
export GOPATH=$HOME/go
export PYENV_ROOT="$HOME/.pyenv"
export POMERIUM_CLI_USER_DATA_DIRECTORY=$HOME/.local/share/pomerium
export STARSHIP_CONFIG=~/.config/starship.toml
export GPG_TTY=$(tty)
export HISTORY_IGNORE="(g\src|g\sra|g\sa|g\srhh|ls|cd|cd ..|pwd|clear|exit|logout|history|alias|unalias|set|unset|env|whoami|date|uptime|tree|code|code \.|vim|nvim|nano|trash|security)( .*)?"
export PATH="$HOME/.bun/bin:$HOME/.antigravity/antigravity/bin:$HOME/.config/herd-lite/bin:$HOME/.codeium/windsurf/bin:$HOME/.console-ninja/.bin:/opt/homebrew/anaconda3/bin:$GOPATH/bin:$PYENV_ROOT/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
openai_key() {
export OPENAI_API_KEY=$(security find-generic-password -a $USER -s openai_api_key -w)
}
# === Cache brew shellenv ===
if [[ ! -f ~/.zsh_brew_cache || ~/.zsh_brew_cache -ot /opt/homebrew/bin/brew ]]; then
/opt/homebrew/bin/brew shellenv > ~/.zsh_brew_cache
fi
source ~/.zsh_brew_cache
# === Zsh options ===
setopt autocd
autoload -U history-search-end
zle -N history-beginning-search-backward-end history-search-end
zle -N history-beginning-search-forward-end history-search-end
bindkey "^[[A" history-beginning-search-backward-end
bindkey "^[[B" history-beginning-search-forward-end
# === Prompt ===
eval "$(starship init zsh)"
# === fnm ===
eval "$(fnm env --use-on-cd --shell zsh)"
# === Lazy-load pyenv ===
pyenv() {
unset -f pyenv
eval "$(command pyenv init -)"
pyenv "$@"
}
# Lazy load Cargo - defers initialization until first use
cargo() {
unset -f cargo rustc rustup
source $HOME/.cargo/env
cargo "$@"
}
rustc() {
unset -f cargo rustc rustup
source $HOME/.cargo/env
rustc "$@"
}
rustup() {
unset -f cargo rustc rustup
source $HOME/.cargo/env
rustup "$@"
}
# === Plugins ===
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
# === Atuin ===
. "$HOME/.atuin/bin/env"
eval "$(atuin init zsh --disable-up-arrow)"
# === Lazy-load gcloud ===
gcloud() {
unset -f gcloud gsutil bq
[ -f "$HOME/.local/google-cloud-sdk/path.zsh.inc" ] && . "$HOME/.local/google-cloud-sdk/path.zsh.inc"
[ -f "$HOME/.local/google-cloud-sdk/completion.zsh.inc" ] && . "$HOME/.local/google-cloud-sdk/completion.zsh.inc"
gcloud "$@"
}
gsutil() { gcloud; gsutil "$@"; }
bq() { gcloud; bq "$@"; }
# === Aliases ===
alias flushdns='sudo dscacheutil -flushcache;sudo killall -HUP mDNSResponder'
alias zshconfig='less ~/.zshrc'
alias nr='npm run'
alias ni='npm i'
alias '$'=''
alias brew='env PATH="${PATH//$(pyenv root)\/shims:/}" brew'
alias dotfiles='/opt/homebrew/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
alias g='git'
alias code='code-insiders'
alias c='cursor -r'
alias p='pnpm'
alias pi='pnpm i'
alias dcu='docker compose up -d'
alias dcd='docker compose down'
alias mermaid='mmdc'
alias sniffly='uvx sniffly init'
# === Functions ===
# checkout a pull request in a git worktree
cpr() {
pr="$1"
remote="${2:-origin}"
branch=$(gh pr view "$pr" --json headRefName -q .headRefName)
git fetch "$remote" "$branch"
git worktree add "../$branch" "$branch"
cd "../$branch" || return
echo "Switched to new worktree for PR #$pr: $branch"
}
rmmerged() {
git branch --merged | grep -v "*" | grep -v \"master\" | xargs -n 1 git branch -d && git remote prune origin
}
nb() {
branch="$1";
git remote -v | grep -q git@github.com:pomerium/ && git checkout -b "nickytonline/$branch" || git checkout -b $branch;
}
db() {
branch="$1";
git remote -v | grep -q git@github.com:pomerium && git branch -D "nickytonline/$branch" || git branch -D $branch;
}
glog() {
git log --oneline --decorate --graph --color | less -R
}

Wrapping Up#

Dev tooling adds up fast and it’s easy to not notice the death by a thousand paper cuts. This lazy loading pattern fixes it without any real downsides.

Big ups to John L. and Claude Code for helping me figure out the bottlenecks and solutions.

Note: The performance impact numbers and comparison table were generated with Claude Code’s help. Mileage may vary depending on your specific setup. 😅

If you want to stay in touch, all my socials are on nickyt.online.

Until the next one!

Photo by Marc Sendra Martorell on Unsplash