██▓ █ ██ ██ ▄█▀ ███▄ ▄███▓ ▓█████▄ ▓█████ ██▒ █▓ ▓██▒ ██ ▓██▒ ██▄█▒ ▓██▒▀█▀ ██▒ ▒██▀ ██▌▓█ ▀▓██░ █▒ ▒██░ ▓██ ▒██░▓███▄░ ▓██ ▓██░ ░██ █▌▒███ ▓██ █▒░ ▒██░ ▓▓█ ░██░▓██ █▄ ▒██ ▒██ ░▓█▄ ▌▒▓█ ▄ ▒██ █░░ ░██████▒▒▒█████▓ ▒██▒ █▄▒██▒ ░██▒ ██▓ ░▒████▓ ░▒████▒ ▒▀█░ ░ ▒░▓ ░░▒▓▒ ▒ ▒ ▒ ▒▒ ▓▒░ ▒░ ░ ░ ▒▓▒ ▒▒▓ ▒ ░░ ▒░ ░ ░ ▐░ ░ ░ ▒ ░░░▒░ ░ ░ ░ ░▒ ▒░░ ░ ░ ░▒ ░ ▒ ▒ ░ ░ ░ ░ ░░ ░ ░ ░░░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
I cut Zsh load time from 400ms to 30ms
2025-06-23
More accurately, I reduced my ZSH startup time from ~412ms to ~28ms.
I had noticed that it was unbearably slow, both in rendering the prompt and starting new shells.
I also have never used Oh-My-ZSH, because I never felt the need for it, and it has a reputation for having abysmal performance. So by all accounts, it should have been much faster than this.
Profiling
Whenever you're measuring anything, it's important not to eyeball it. In this case, we can profile ZSH using a simple one-liner.
Startup felt slow and tedious. But we need to make sure we properly
measure. I reverted my performance commits and found that ZSH takes 412ms to start using:
time zsh -i -c exit
I then used the following to profile which exact parts of my ZSH were taking the most time:
zmodload zsh/zprof
# ...
zprof
I'll spare the details for brevity; but here's what made the biggest difference...
Replace nvm
with fnm
Replacing nvm
with `fnm` gh
was the single biggest improvement, because the
majority of the startup time was taken by evaluating nvm
.
Changed from a custom prompt to Starship
I had created a complex ZSH PS1 prompt that got the working directory, and
called git
a bunch of times. This was extremely slow.
Starship renders almost instantly. Its hooks into git use the
gitoxide crate, which is much faster than calling the git
command and
parsing its output.
Pre-cached evaluated scripts
I use Chezmoi to patch my dotfiles from my Git repository using a command. Its powerful templating and scripting features make it incredible for declaratively provisioning packages and configuration across multiple machines and operating systems.
Using Chezmoi allows us to pre-compute the output of a lot of things we'd
otherwise eval in our .zshrc
or .zprofile
.
For example, Homebrew requires evaluation to set a bunch of environment variables. Here is a cross-platform (Mac OS and Linux) snippet that can pre-compute it:
{{- if lookPath "/opt/homebrew/bin/brew" }}
{{ output "/opt/homebrew/bin/brew" "shellenv" | trim }}
{{- end }}
{{- if lookPath "/home/linuxbrew/.linuxbrew/bin/brew" }}
{{ output "/home/linuxbrew/.linuxbrew/bin/brew" "shellenv" | trim }}
{{- end }}
I love using Atuin to sync shell history, which can also be pre-computed:
{{- if lookPath "atuin" }}
{{ output "zsh" "-c" "atuin init zsh" | trim }}
{{- end }}
Finally, even Starship can be pre-computed:
{{- if lookPath "starship" -}}
{{ output "starship" "init" "zsh" "--print-full-init" | trim }}
{{- end -}}
And we can also pre-compute fnm
:
# fnm zsh
#
# Using Chezmoi templating, caches the evaluation of fnm's init command for performance.
{{- if lookPath "fnm" }}
{{ output "zsh" "-c" "fnm env --use-on-cd --shell zsh --version-file-strategy=recursive" | trim }}
{{- end }}
Cache $PATH
I also make heavy use of caching for many of the tools that need to be added
to the $PATH
environment variable. Usually these will give you a command
to evaluate in your .zshrc
or .zprofile
which will add them to
$PATH
. But by caching the value, we skip all that:
export GOPATH="${XDG_DATA_HOME:-$HOME/.local/share}/go"
# path is an array that zsh syncs with the PATH environment variable
path=(
$path
~/.local/bin
~/bin
~/.cargo/bin
~/.bun
~/.atuin/bin
$XDG_CONFIG_HOME/git/bin
$XDG_DATA_HOME/uv/tools
$GOPATH/bin
$HOMEBREW_PREFIX/opt/rustup/bin
$HOMEBREW_PREFIX/opt/clamav/bin
$HOMEBREW_PREFIX/opt/omnisharp/bin
$HOMEBREW_PREFIX/opt/postgresql@17/bin
$HOMEBREW_PREFIX/bin
$HOMEBREW_PREFIX/sbin
/usr/local/go/bin
/snap/bin
/usr/bin
/usr/local/bin
/bin
/usr/sbin
/sbin
)
export RUSTUP_HOME="$XDG_DATA_HOME/rustup"
export CARGO_HOME="$XDG_DATA_HOME/cargo"
export DOCKER_CONFIG="$XDG_CONFIG_HOME/docker"
export SHELL="$HOMEBREW_PREFIX/bin/zsh"
export LANG="en_AU.UTF-8"
export LC_ALL="en_AU.UTF-8"
Cache .zcompdump file
Thanks to the author of this awesome gist, I found that I can pre-compute the .zcompdump file daily.
# On slow systems, checking the cached .zcompdump file to see if it must be
# regenerated adds a noticable delay to zsh startup. This little hack restricts
# it to once a day. It should be pasted into your own completion file.
#
# The globbing is a little complicated here:
# - '#q' is an explicit glob qualifier that makes globbing work within zsh's [[ ]] construct.
# - 'N' makes the glob pattern evaluate to nothing when it doesn't match (rather than throw a globbing error)
# - '.' matches "regular files"
# - 'mh+24' matches files (or directories or whatever) that are older than 24 hours.
#
# From: https://gist.github.com/ctechols/ca1035271ad134841284
autoload -Uz compinit
if [[ -n ${ZDOTDIR}/.zcompdump(#qN.mh+24) ]]; then
compinit;
else
compinit -C;
fi;
Final profile
With all the above changes combined, we come to a mere ~28ms. Amazing!
Before
zsh -i -c exit 0.21s user 0.22s system 120% cpu 0.412 total
After
zsh -i -c exit 0.02s user 0.01s system 101% cpu 0.028 total
Anecdotally, it feels snappy; very satisfying for my primordial lizard brain.
Busting cache
Because it's Chezmoi, the cache can be busted by simply running:
chezmoi apply
And all values are re-computed.
Conclusion
What did we learn? First, measure when optimising for a metric. Second, caching at the right time drastically improves performance. For me, this is when I provision my dotfiles. If I haven't updated anything or made any changes, chances are that nothing needs to be re-evalated.
My dotfiles are available on GitHub gh .