Shell Performance¶
Shell startup time was reduced from ~2.37s to ~0.08s — a 97% improvement — through a series of targeted changes.
What changed¶
Replaced chruby + nvm with mise — the biggest win. The previous setup sourced two chruby scripts, ran brew --prefix at startup, and used lazy-loader stub functions for nvm/node/npm/npx to avoid nvm's ~500ms cold start. All of that is now one line: eval "$(mise activate zsh)", which adds ~5ms and handles Ruby, Node, Python, Java, and Go with automatic per-project version switching.
Removed dynamic PATH calls — a $(ruby -e 'puts Gem.bindir') subshell that spawned a full Ruby process on every new shell has been removed.
Starship Ruby module disabled — starship's Ruby module executed Ruby on every prompt render, causing intermittent timeout warnings. Disabled in config/starship.toml; command_timeout = 2000 is set as a safety net for other modules.
Cached compinit — zsh rebuilds its completion dump (~/.zcompdump) on every shell start by default. A 24-hour freshness check now skips the rebuild (compinit -C) unless the dump is older than a day. Saves ~30–50ms per shell start with no visible downside.
Benchmark (MacBook Pro, Apple Silicon)¶
| Measurement | Time |
|---|---|
| Original (chruby + nvm + Starship Ruby warnings) | ~2.37s |
| After lazy NVM + removing Ruby PATH call | ~0.58s |
| After replacing chruby + nvm with mise | ~0.11s |
| After compinit caching | ~0.08s |
| Total improvement | ~97% faster |
Optimization methodology¶
Every change above came from the same loop. Use it whenever startup feels slow
again (a new plugin, a heavy eval in ~/.zshrc.local, etc.):
- Measure a baseline — get a repeatable number before touching anything (see Measuring startup time). Always compare against the same machine and an otherwise-idle system.
- Profile to find the cost — don't guess. Use
zprof(below) to rank what actually consumes time during an interactive start. - Attack the biggest cost first, using one of three levers in order of
preference:
- Eliminate — remove work that runs on every shell. The single
biggest win here was deleting a
$(ruby -e 'puts Gem.bindir')subshell that spawned a full Ruby process per shell. - Replace — swap a slow tool for a faster one. chruby + nvm (two sourced
scripts, a
brew --prefixcall, and nvm lazy-loaders) became one line:eval "$(mise activate zsh)". - Cache / defer — pay a cost once and reuse it, or push it to first use.
compinit -Cskips rebuilding~/.zcompdumpunless it is older than a day; disabling starship's Ruby module avoids running Ruby on every prompt.
- Eliminate — remove work that runs on every shell. The single
biggest win here was deleting a
- Re-measure — confirm the change helped and didn't regress correctness (completions still work, runtimes still switch per project).
- Repeat until the next-biggest cost isn't worth chasing.
Keep machine-specific weight out of the tracked rc
Slow, machine-only setup (corporate tooling, one-off evals) belongs in
~/.zshrc.local, not the tracked home/zshrc. That keeps the shared
baseline fast for every profile and machine.
Measuring startup time¶
Time a full interactive start, averaged over several runs:
For a tighter, statistically warmed-up number, use hyperfine
(brew install hyperfine):
Profiling with zprof¶
Timing tells you how slow; zprof (a zsh builtin module) tells you what is
slow. Enable it at the very top of your rc, start a shell, then read the report:
# Temporarily add as the FIRST line of ~/.zshrc (or ~/.zshrc.local):
zmodload zsh/zprof
# ...rest of your rc...
# Then open a new shell and run:
zprof # prints functions ranked by cumulative time
The top entries are where to focus step 3 above. Remove the zmodload line once
you're done. For a line-by-line view of what runs at startup, you can also trace
it:
The runtime manager (mise) and plugin manager (sheldon) are the two heaviest
startup contributors by design — see the Glossary for what
each does and the Architecture page for how they're wired in.