- Published on
Speeding Up Zsh
- Authors
- Name
- Joshua Yin
- @ijaeshi
Tags
Previous Article
Next Article
Motivations
Ever since I embarked on my programming journey, Zsh has been my trusty shell of choice, consistently serving me well. But as time went on and my .zshrc
file grew longer, I couldn't help but notice that startup times have gotten slow. Unbearably slow. Well... maybe not that slow, but slow enough to be just a tad irritating.
Recently, I've had a little bit too much time on my hands, so I finally decided to invest some effort into optimizing my setup. Needless to say, I'm quite happy with the results: I was able to slash Zsh's startup time down from 0.65 seconds to a whopping 0.03 seconds!
I hope that anyone else struggling with sluggish startup times will find these optimizations helpful.
nvm
The main culprit: For most people, myself included, the main culprit behind sluggish startup times will be none other than nvm. The issue with nvm
is that it is always loaded, leading to unnecessary slowdowns in the many instances I'm not working on a node project. Thus, the natural solution would be to only lazy load it when needed.
nvm
Lazy loading There are several ways to lazy load nvm
, such as zsh-nvm, but I've personally found the nvm plugin bundled with Oh My Zsh
to be the fastest and easiest to configure. Just add the following lines to your .zshrc
to enable lazy loading:
plugins += (nvm)
# add the following before Oh My Zsh is sourced
zstyle ':omz:plugins:nvm' lazy yes
fnm
instead?
Or just use fnm is a lightweight alternative to nvm, written in rust. Like nvm
, it manages your node versions, but is much faster, with almost no effect on shell loading times. I'd highly recommend checking fnm
out if there isn't a specific feature of nvm
that you need, or if you don't like the idea of installing a plugin or writing a script to lazy load nvm
.
Other Virtual Environments
While nvm
was certainly the main cause of slowdown, other virtual environments contribute to startup times as well. These typically work by running an eval statement in your .zshrc
file:
eval "$(rbenv init -)"
eval "$(pyenv init -)"
As it turns out, the output of the eval statement is almost always static, so it often isn't necessary to compute every time. Instead, we can cache the result on the first run so that the overhead in recomputing it can be avoided during subsequent runs.
Caching evals
To cache evals, install the amazing evalcache plugin. Now, simply replace all instances of eval
with _evalcache
:
_evalcache pyenv init -
_evalcache rbenv init -
Note that a minor caveat to caching is that whenever you update your virtual environments, you may need to manually recache the results by running _evalcache_clear
.
RTX: The end-all solution
If you don't have an attachment to any particular virtual environment tool, then it might be worth trying out RTX. While still a fairly young project, it promises to replace the need for all existing language environment tools like nvm
by offering a single interface to manage them all. The best part is that it's fully backwards-compatible with legacy language-specific files like .nvmrc
or .python-version
, meaning that you can literally use it as a drop-in replacement in existing projects. RTX
is actually based on an older project asdf with similar goals, but offers many advantages, including being written in rust and not being reliant on shims, making it much faster than asdf
.
Once you have it installed, you can initialize it on startup by adding the following to your .zshrc
:
_evalcache rtx activate zsh
By now, your shell startup time should be vastly improved. However, there's still more that can be done!
Alternative plugin managers
For the longest time, I've been content with the popular Oh My Zsh framework. When conducting a quick internet search for customizing Zsh and installing plugins, Oh My Zsh
is typically the first thing that comes up, and rightfully so — it's easy to set up and comes with a large library of plugins out of the box. But over time, I've started to realize that its comprehensive set of features and ease of use comes at a slight performance cost. Oh My Zsh
has been around for a long time and has inevitably begun to show its age. It's old, bloated, and widely known to be slow.
If only there was a way to have speed without sacrificing functionality...
Zim
: A modern alternative to Oh My Zsh
Zim is a blazingly fast plugin manager that aims to solve all these issues and more, while also providing a sensible set of defaults and full support for the Oh My Zsh
and Prezto
plugin libraries. Moreover, it automatically installs and updates custom plugins via a .zimrc
file. No more manually running git clone
each time you reinstall your shell!
It's worth mentioning that there are many alternative Zsh frameworks and plugin managers, like Prezto, antigen, zgen, zplug, etc. However, I prefer Zim
as it provides many useful features and sane defaults without compromising on performance, and crucially, is still being maintained to this day.
Migrating from Oh My Zsh
to Zim
was a fairly straightforward process. I just followed the handy installation guide and added the plugins I wanted to my .zimrc
file. For anyone seeking a practical example, feel free to explore my dotfiles repository.
zinit
?
What about Before stumbling across Zim
, I also looked into zinit, another performance-centric plugin manager that provides an innovative way to asynchronously load plugins via its turbo mode in addition to still being actively maintained. While zinit
does offer a lot in terms of customizability, I personally found zinit
too cumbersome to configure and get turbo mode working correctly, for what seemed like only minimal performance gains over Zim
.
Zsh-defer
Async execution with Turns out turbo mode isn't necessarily a feature exclusive to zinit. Zsh-defer, as the name implies, is a plugin that allows you to defer the execution of a Zsh command until Zsh has nothing else to do and is waiting for user input. To install the plugin, add the following line to your .zimrc
file:
zmodule romkatv/zsh-defer -d
Then source the plugin and add zsh-defer
before any statement you want to defer:
source ${ZIM_HOME}/modules/zsh-defer/zsh-defer.plugin.zsh
zsh-defer _evalcache fnm env --use-on-cd
zsh-defer _evalcache pyenv init -
zsh-defer _evalcache rbenv init -
zsh-defer _evalcache zoxide init zsh
You can even defer plugins loaded by Zim
! Just be careful as not all plugins are intended to be loaded this way and some may not even function properly.
# Initialize zsh-defer.
source ${ZIM_HOME}/modules/zsh-defer/zsh-defer.plugin.zsh
# Install missing modules, and update ${ZIM_HOME}/init.zsh if missing or outdated.
if [[ ! ${ZIM_HOME}/init.zsh -nt ${ZDOTDIR:-${HOME}}/.zimrc ]]; then
zsh-defer source ${ZIM_HOME}/zimfw.zsh init -q
fi
# Initialize modules.
zsh-defer source ${ZIM_HOME}/init.zsh
By implementing this approach, I managed to reduce my Zsh startup time down to an impressive 0.02 seconds! Nevertheless, it's worth mentioning that I encountered a few issues with certain plugins. For instance, Zsh-vim-mode failed to properly set the cursor style within tmux, the environment module did not configure specific options, and some of my aliases got overridden by those defined in the utility module.
Ideally, I would like to skip deferring those plugins in particular. Thankfully, it's not too difficult to write some shell script that accomplishes this for us.
# Initialize modules.
# don't defer loading the following plugins
skip_defer=(environment utility zsh-vim-mode)
for zline in ${(f)"$(<$ZIM_HOME/init.zsh)"}; do
if [[ $zline == source* ]]; then
skip_source=0
for skip in "${skip_defer[@]}"; do
if [[ $zline == *"/modules/$skip/"* ]]; then
skip_source=1
break
fi
done
if [[ $skip_source -eq 0 ]]; then
zsh-defer -c "${zline}"
else
eval "${zline}"
fi
else
eval "${zline}"
fi
done
Now I can provide an array skip_defer
specifying the plugins I do not want to defer. This only brings up the startup time a tiny bit, to 0.03 seconds. Hooray!
By now, your shell should be sufficiently fast, making the next option potentially unnecessary. However, if you opted out of any of the previous optimizations, then the next one could be beneficial.
Powerline10k Instant Prompt
Powerline10k is a performant and highly-customizable prompt that has become quite popular among the Zsh community. For our purposes, we are interested in its Instant Prompt feature, which displays the shell prompt before Zsh finishes starting up. While this doesn't actually eliminate startup lag, it gives the appearance of doing so by immediately showing the text for the shell prompt instead of waiting until after Zsh starts up.
To install the plugin, add the following line to your .zimrc
file:
zmodule romkatv/powerlevel10k
Then enable the instant prompt and source your Powerline10k
configuration:
# Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc.
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi
[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
Summary
All in all, I was able to reduce my Zsh's startup time by more than 95%. The table below summarizes my results in more detail:
Configuration | Average Startup Time |
---|---|
ohmyzsh | 0.65 s |
ohmyzsh +lazy loading nvm | 0.43 s |
ohmyzsh +lazy loading nvm +evalcache | 0.35 s |
ohmyzsh +fnm +evalcache | 0.35 s |
zim | 0.45 s |
zim +lazy loading nvm | 0.29 s |
zim +fnm | 0.27 s |
zim +fnm +evalcache | 0.19 s |
zim +fnm +zsh-defer (evals) | 0.17 s |
zim +fnm +evalcache +zsh-defer (evals) | 0.07 s |
zim +fnm +evalcache +zsh-defer (evals & most plugins) | 0.03 s |
zim +fnm +evalcache +zsh-defer (evals & all plugins) | 0.02 s |
To measure the startup time, I used the following shell function, and captured the average time over 10 iterations:
# function to gauge zsh's startup time
function timezsh() {
shell=${1-$SHELL}
for i in $(seq 1 10); do /usr/bin/time $shell -i -c exit; done
}
As you can see, we were able to achieve stellar gains in performance while keeping all the same tools and commands. We did this by:
- Lazy loading
nvm
or switching tofnm
- Caching the results of eval statements with the
evalcache
plugin - Deferring the execution of evals and plugins with the
Zsh-defer
plugin - Migrating from
Oh My Zsh
toZim
Helpful Resources
I found the following resources particularly helpful while working on this. I would like to express my gratitude to the authors for sharing their insights: