DEV Community

Cover image for How I Used Claude Code to Speed Up My Shell Startup by 95%
Nick Taylor
Nick Taylor Subscriber

Posted on • Edited on • Originally published at nickyt.co

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

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:

for i in 1 2 3 4 5; do /usr/bin/time zsh -i -c exit 2>&1; done
Enter fullscreen mode Exit fullscreen mode

Yikes:

0.94 real
0.71 real
0.74 real
0.73 real
0.72 real
Enter fullscreen mode Exit fullscreen mode

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:

Tool Impact Why It Sucks
nvm ~300-500ms Loads the entire Node.js environment every time
pyenv init ~100-200ms Python version management initialization
security command ~50-100ms Fetching API key from macOS Keychain
brew shellenv ~30-50ms Runs a subshell to get Homebrew paths
gcloud completion ~20-30ms Google 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:

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

now, I use wrapper functions:

# 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 "$@"; }
Enter fullscreen mode Exit fullscreen mode

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

# Before
eval "$(pyenv init -)"

# After
pyenv() {
  unset -f pyenv
  eval "$(command pyenv init -)"
  pyenv "$@"
}
Enter fullscreen mode Exit fullscreen mode

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

Google Cloud SDK

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 "$@"; }
Enter fullscreen mode Exit fullscreen mode

Homebrew Caching

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

# 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
Enter fullscreen mode Exit fullscreen mode

API Key Laziness

I was hitting the macOS Keychain on every shell startup:

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

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

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 "$@"
}
Enter fullscreen mode Exit fullscreen mode

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:

# === Lazy-load OPENAI_API_KEY ===
openai_key() {
  export OPENAI_API_KEY=$(security find-generic-password -a $USER -s openai_api_key -w)
}
Enter fullscreen mode Exit fullscreen mode

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:

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

Average: ~40ms

Before After Improvement
770ms 40ms 95% 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:

time zsh -i -c exit
Enter fullscreen mode Exit fullscreen mode

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:

# Add to top of .zshrc
zmodload zsh/zprof

# Add to bottom
zprof
Enter fullscreen mode Exit fullscreen mode

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.

# === 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
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (13)

Collapse
 
andypiper profile image
Andy Piper

Oh, this was a huge tip. I think I managed to get mine down from ~1s to ~0.1s! Thanks for suggesting this as a path to happiness.

Collapse
 
nickytonline profile image
Nick Taylor

Woah! That is a huge difference!

Yes, that's awesome!

Collapse
 
mray profile image
mray • Edited

awesome stuff @nickytonline! went through this to help my insanely slow startup. went from ~1.4s to ~0.45s ~0.24s

thanks! 🔥

Collapse
 
nickytonline profile image
Nick Taylor

Awesome!

Hackerman from Kung Fury putting on a Nintendo Power glove

Collapse
 
nadeem_rider profile image
Nadeem Zia

Good work

Collapse
 
nickytonline profile image
Nick Taylor

Amy Poehler being cool

Collapse
 
checkmycreds profile image
Check My Creds

Good post

Collapse
 
nickytonline profile image
Nick Taylor

Some people online asked me why it didn't suggest fnm over nvm. It did, but I was used to nvm, so kept it. That said, now, I'm going to give fnm a go.

GitHub logo Schniz / fnm

🚀 Fast and simple Node.js version manager, built in Rust

Fast Node Manager (fnm) Amount of downloads GitHub Actions workflow status

🚀 Fast and simple Node.js version manager, built in Rust

Blazing fast!

Features

🌎 Cross-platform support (macOS, Windows, Linux)

✨ Single file, easy installation, instant startup

🚀 Built with speed in mind

📂 Works with .node-version and .nvmrc files

Installation

Using a script (macOS/Linux)

For bash, zsh and fish shells, there's an automatic installation script.

First ensure that curl and unzip are already installed on your operating system. Then execute:

curl -fsSL https://fnm.vercel.app/install | bash
Enter fullscreen mode Exit fullscreen mode

Upgrade

On macOS, it is as simple as brew upgrade fnm.

On other operating systems, upgrading fnm is almost the same as installing it. To prevent duplication in your shell config file, pass --skip-shell to the install command:

curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell
Enter fullscreen mode Exit fullscreen mode

Parameters

--install-dir

Set a custom directory for fnm to be installed. The default is $XDG_DATA_HOME/fnm (if $XDG_DATA_HOME is not defined it falls…

Collapse
 
moopet profile image
Ben Sinclair

There's no way I'd notice a sub-second delay when opening a new tab. It'd take me that long to go from the hotkey for "new tab" to starting to type into it, and my keystrokes would be buffered anyway. I just timed it and it takes about 750ms to start, but I include the kitchen sink for different environments.

It's different use cases, I guess, but I open most of the terminals I need in screen/tmux/zellij and flip between them, which takes essentially no time, rather than opening and closing new ones. RAM's cheap; I can afford to have a dozen shells active at once.

Lazy-loading environment variables from (relatively) expensive calls makes sense.

The wrappers you use are more of a problem:

npm() {
  nvm use default --silent
Enter fullscreen mode Exit fullscreen mode

This means every time you switch to a different node version for a specific project and then run npm, you'll switch back to the default, which could mess up your whole day. I'm all for wrapping up commands like this (I do it myself to add a git cd command for example) but I don't like side-effects like this one. Also, if the default node version is a prerequisite for npm in this use case, I think it'd be better to use && to join the commands so it fails if nvm doesn't work for whatever reason.

I guess the take-home from your investigation is that node is a hog. Paint me surprised!

Collapse
 
thnh_dv profile image
Thành dv

abc

Collapse
 
nickytonline profile image
Nick Taylor

123

Collapse
 
yueyong profile image
YueYong

Wow, this is an impressive speed boost! Using Claude Code’s lazy-loading wrappers for nvm, pyenv, and gcloud to cut startup from 770ms to 40ms is next-level productivity.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.