I Couldn't Stand oh-my-zsh's Speed, So I Switched to zinit
I've been using oh-my-zsh for several years—almost since the first day I started learning to code, it has been there. But over the past few years my config has grown more and more, with plugins, completions, and initialization for all sorts of tools piled into a single .zshrc file, so every time I open a terminal I have to wait a bit before I can start typing. Opening a new tab, opening a terminal in the editor, splitting panes—each time I have to wait, and sometimes typing feels a little laggy.
At first I also thought about simplifying things and removing a few plugins, but no matter how much I cut, the improvement wasn't obvious, and I really do use many of those plugins. So I finally decided to just switch to a different plugin manager.
Where the Slowness Comes From
Before switching, I first looked into where exactly the slowness was coming from. zsh ships with a zprof module that can measure how much time each function takes during startup. All you need is to edit .zshrc by adding zmodload zsh/zprof to the first line and zprof to the last line. Then open a new terminal and it will print a timing table.
# ~/.zshrc 第一行
zmodload zsh/zprof
# ... 原来的配置 ...
# ~/.zshrc 最后一行
zprof
I also used time to roughly measure the overall startup time.
for i in {1..10}; do time zsh -i -c exit; done
According to the results, the time was mainly spent on:
- oh-my-zsh itself has to load its entire set of libs, then source plugins one by one, and finally run
compinitto initialize completions. All of this is synchronous, and the prompt doesn't appear until everything is done. - Initialization for various tools, such as
starship init,zoxide init,fzf --zsh,mise activate,gh copilot alias; each one iseval "$(xxx)", meaning every startup first runs a subprocess and then executes its output. - Miscellaneous
PATH, function definitions, and aliases—none slow on their own, but together they add up.
The problem with oh-my-zsh is that it has no way to defer these things. Whether or not a plugin is needed right away, it has to be fully loaded before the prompt appears.
zinit
zinit is a zsh plugin manager, originally developed by zdharma. In 2021 the original author deleted the repository, and it is now maintained by the community at zdharma-continuum/zinit.
What attracted me most was Turbo mode. Simply put, it displays the prompt first so you can start typing immediately, and then loads plugins one by one in the background. There is always a little delay between seeing the prompt and typing the first command, and that bit of time is enough to load most plugins, so it feels like the terminal is usable the moment it opens.
In addition, zinit can directly load individual files from oh-my-zsh; it calls this usage a snippet. So I don't need the entire oh-my-zsh framework—I can just bring over the few libs and plugins I commonly use, and the abbreviations and behaviors I was used to are still there.
Installation
The official zinit installer script adds a block of code to the top of .zshrc. If zinit doesn't exist, it automatically clones it and then loads it.
### Added by Zinit's installer
if [[ ! -f $HOME/.local/share/zinit/zinit.git/zinit.zsh ]]; then
print -P "%F{33} %F{220}Installing %F{33}ZDHARMA-CONTINUUM%F{220} Initiative Plugin Manager (%F{33}zdharma-continuum/zinit%F{220})…%f"
command mkdir -p "$HOME/.local/share/zinit" && command chmod g-rwX "$HOME/.local/share/zinit"
command git clone https://github.com/zdharma-continuum/zinit "$HOME/.local/share/zinit/zinit.git" && \
print -P "%F{33} %F{34}Installation successful.%f%b" || \
print -P "%F{160} The clone has failed.%f%b"
fi
source "$HOME/.local/share/zinit/zinit.git/zinit.zsh"
autoload -Uz _zinit
(( ${+_comps} )) && _comps[zinit]=_zinit
This way, when I move to a new computer, I just need to put .zshrc there, and the first time I open a terminal it will install zinit by itself; plugins will also be downloaded automatically the first time they are loaded.
In the configuration below I use zi, which is a shorthand included with zinit and is the same command as zinit.
A Few Key ices
zinit's configuration looks a bit strange because it makes heavy use of modifiers called ices. An ice is written after zi ice or directly before the for syntax, and only applies to the very next load—like an ice cube, it melts after one use, which is probably where the name comes from.
The main ones I use are these.
| ice | Effect |
|---|---|
wait | Enables Turbo mode and waits until the prompt appears before loading |
lucid | Don't print a message when background loading finishes |
light-mode | Doesn't track what the plugin does, so loading is faster; the trade-off is that you can't use zinit unload to unload it |
blockf | Prevents the plugin from modifying fpath itself; completions are managed centrally by zinit |
depth"1" | Only pulls the latest commit when cloning the plugin |
as"completion" | Treats this file as a completion file |
atinit / atclone / atpull | Runs a command before loading, after cloning, or after updating |
Moving oh-my-zsh Over
oh-my-zsh's libs and plugins can be loaded directly using the OMZL:: and OMZP:: prefixes. I listed all the ones I used before and lazy-load them all.
zi wait lucid light-mode for \
OMZL::theme-and-appearance.zsh \
OMZL::key-bindings.zsh \
OMZL::clipboard.zsh \
OMZL::history.zsh \
OMZL::git.zsh \
OMZP::git \
OMZP::history \
OMZP::eza \
OMZP::extract \
OMZP::git-commit \
OMZP::rust \
OMZP::copyfile
OMZL is for oh-my-zsh libs, such as key bindings, history settings, and git-related helper functions, while OMZP is for plugins. So OMZP::git is the same plugin that provided a bunch of git abbreviations, and gst, gco still work as usual.
One thing to note, though: oh-my-zsh used to set some zsh options for you. Now that the framework is gone, you have to write these yourself.
setopt promptsubst
setopt interactive_comments
setopt auto_cd
setopt auto_pushd
setopt pushd_ignore_dups
setopt pushdminus
Prompt
The prompt I use is starship. If starship were also lazy-loaded, the terminal would first show zsh's bare default prompt, then suddenly switch to the starship look, which is a bit unpleasant to watch.
So first I hand-wrote a prompt in .zshrc that looks the same as oh-my-zsh's robbyrussell theme: a green arrow when a command succeeds, a red arrow when it fails, followed by the current directory name. This prompt doesn't depend on any plugin, so it is there as soon as the terminal opens.
PROMPT="%(?:%{$fg_bold[green]%}%1{➜%} :%{$fg_bold[red]%}%1{➜%} ) %{$fg[cyan]%}%c%{$reset_color%} "
Then starship loads in the background and takes over the prompt once it finishes. Because the two look similar, the switch is basically imperceptible.
I also switched to installing starship itself with zinit. from"gh-r" downloads the compiled binary directly from GitHub Releases, while atclone runs starship init zsh once after downloading and saves the output as init.zsh, and from then on each startup can just source that file without running starship init. atpull"%atclone" means regenerating it once after an update.
if $IS_VSCODE; then
# vscode has _precmd and _preexec hooks conflict with starship if we use async
zi ice lucid as"command" from"gh-r" \
atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
atpull"%atclone" src"init.zsh"
else
zi ice wait lucid as"command" from"gh-r" \
atclone"./starship init zsh > init.zsh; ./starship completions zsh > _starship" \
atpull"%atclone" src"init.zsh"
fi
zi light starship/starship
I special-cased VS Code here. Because VS Code's terminal has its own shell integration, it attaches precmd and preexec hooks; if starship is loaded asynchronously, it conflicts with them, so in VS Code I load starship synchronously. The way to determine this is to check the TERM_PROGRAM variable.
IS_VSCODE=false
if [[ "$TERM_PROGRAM" == "vscode" ]]; then
IS_VSCODE=true
fi
Other Plugins
Plugins on GitHub work the same way—just write the repository name.
zi wait lucid light-mode depth"1" blockf for \
Aloxaf/fzf-tab \
orbstack/orbstack \
ajeetdsouza/zoxide
Among them, fzf-tab replaces zsh's native completion menu with fzf; after pressing Tab you can fuzzy-search and preview. I added a preview to the completion for cd that lists the directory contents with eza.
zstyle ':completion:*' menu no
zstyle ':fzf-tab:complete:cd:*' fzf-preview 'eza -1 --color=always $realpath'
zstyle ':fzf-tab:*' switch-group '<' '>'
Completions
Completions require a bit more care. zsh completions need compinit to initialize first, and it's best to run it only once because it isn't fast itself. But plugins are loaded one after another in the background, and each plugin may bring its own completions, so compinit has to wait until all completions are in place before running.
zinit's approach is to first record the compdef calls invoked in each plugin while loading, then use zicompinit to initialize completions, and then use zicdreplay to replay the previously recorded compdef calls. So I attached this step to the last-loaded zsh-syntax-highlighting, using atinit to run it before that plugin loads.
zi wait lucid light-mode depth"1" for \
atinit"ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay" \
zsh-users/zsh-syntax-highlighting \
blockf \
zsh-users/zsh-completions
ZINIT[COMPINIT_OPTS]=-C makes compinit skip the check and use the already-generated completion cache directly, which makes it a bit faster.
Individual completion files, such as those for docker, fd, and ripgrep, can also be loaded directly by URL; adding as"completion" treats them as completion files and places them in the right location.
zi wait lucid light-mode as"completion" for \
OMZP::docker/completions/_docker \
OMZP::docker-compose/_docker-compose \
https://raw.githubusercontent.com/sharkdp/fd/master/contrib/completion/_fd \
id-as"_rg" \
https://raw.githubusercontent.com/BurntSushi/ripgrep/master/crates/core/flags/complete/rg.zsh
The ripgrep completion file is named rg.zsh, but zsh requires completion files to start with an underscore, so I used id-as"_rg" to rename it.
Splitting It Into Modules
Everything used to be in a single .zshrc file, and scrolling through more than two hundred lines was tiring. This time I split out the part I wrote myself and put it into ~/.config/zinit/ as four files.
~/.config/zinit/
├── env.zsh # 环境变量和 PATH
├── func.zsh # 自己写的函数
├── active.zsh # 需要 eval 激活的工具,比如 fzf 和 mise
└── alias.zsh # 别名
Then I load them all at once with zinit, again lazily.
zi ice wait'0' lucid light-mode blockf multisrc'~/.config/zinit/{env,func,active,alias}.zsh'
zi light z-shell/null
multisrc can source multiple files at once, but an ice always has to be attached to a load, so here I borrowed an empty plugin, z-shell/null, which does nothing itself and just serves as a carrier for these files.
active.zsh contains the eval initializations; they have now all been moved to after the prompt appears.
# Activate some plugins and completions
source <(fzf --zsh)
eval "$(mise activate zsh)"
func.zsh contains some small functions I've accumulated over time, such as git-amend-date for editing the date of the last commit, plus a function that browses a directory with yazi and then cd s there, called yy, and a check for whether cargo is the wrong one, called check_cargo.
# git last commit date edit
function git-amend-date() {
local date=$1
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" git commit --amend --no-edit --date="$date" --no-verify
}
# check if using correct cargo
function check_cargo() {
cargo_path=$(which cargo 2>/dev/null)
if [[ -z "$cargo_path" ]]; then
echo "Cargo is not installed or not in PATH."
return 1
fi
if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
echo "$cargo_path cargo path error"
fi
}
This way, .zshrc only contains zinit's load order and a few zsh options; it's now under a hundred lines total, and when I want to change something I can quickly find the corresponding file.
Overall Load Order
To wrap up, here is roughly what happens when you open a terminal.
The solid-line parts are synchronous—just loading zinit and setting a few options, which is very fast. The dashed-line parts are all in the background and complete one after another after the prompt appears.
Using It
After the switch, opening a terminal is basically instant; I can type as soon as the prompt appears, and I no longer have to wait when splitting panes or opening new tabs. And the things I was used to from oh-my-zsh—git abbreviations, key bindings, history behavior—are all still there, so there's almost nothing to readjust to.
Updating plugins normally is also simple: zi update --parallel updates all plugins in parallel, and zi self-update updates zinit itself.