<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>shell on Saurabh Kumar</title><link>https://saurabh-kumar.com/tags/shell/</link><description>Recent content in shell on Saurabh Kumar</description><generator>Hugo</generator><language>en-US</language><copyright>Copyright © 2025, Saurabh Kumar.</copyright><lastBuildDate>Mon, 11 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://saurabh-kumar.com/tags/shell/index.xml" rel="self" type="application/rss+xml"/><item><title>How I Optimized My Zsh Startup Time</title><link>https://saurabh-kumar.com/articles/2026/05/how-i-optimized-my-zsh-startup-time/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2026/05/how-i-optimized-my-zsh-startup-time/</guid><description>&lt;p&gt;My zsh prompt was taking close to a second to appear on a fresh terminal. Not catastrophic. But you feel it every time tmux spawns a new pane, and for me that is constantly. The config had grown for years, one &lt;code&gt;eval&lt;/code&gt; and one plugin at a time, and nobody had been auditing it, least of all me.&lt;/p&gt;&#10;&lt;p&gt;What follows is the audit, and the eight things I changed. The numbers are estimates, not measurements, and I will say so again at the end.&lt;/p&gt;&#10;&lt;h2 id="measure-first"&gt;Measure first&lt;/h2&gt;&#10;&lt;p&gt;Before I touched anything, I measured. Two tools did the work:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Cold-start benchmark (run a few times, watch the variance):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; i in &lt;span class="o"&gt;{&lt;/span&gt;1..5&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; zsh -i -c exit&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# In-shell flamegraph:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 1. Uncomment `zmodload zsh/zprof` at the top of .zshrc&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. Add `zprof` at the bottom&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 3. Open a new shell&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zprof &lt;span class="p"&gt;|&lt;/span&gt; head -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;zprof&lt;/code&gt; is the one that helped most. It prints which function calls and &lt;code&gt;eval&lt;/code&gt;s are burning time, sorted by self-time and call count. That is enough to stop guessing.&lt;/p&gt;&#10;&lt;h2 id="1-stop-forking-brew---prefix-five-times-per-shell"&gt;1. Stop forking &lt;code&gt;brew --prefix&lt;/code&gt; five times per shell&lt;/h2&gt;&#10;&lt;p&gt;My macOS config had this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix coreutils&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix findutils&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix gnu-sed&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix grep&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix curl&lt;span class="k"&gt;)&lt;/span&gt;/bin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Five forks of &lt;code&gt;brew&lt;/code&gt;, which is a Ruby program, on every shell, just to resolve paths that always end up under &lt;code&gt;${HOMEBREW_PREFIX}/opt/&amp;lt;formula&amp;gt;&lt;/code&gt; anyway. Each fork is about 35 ms on Apple Silicon, so that is roughly 180 ms gone before I have typed a single character.&lt;/p&gt;&#10;&lt;p&gt;Fix:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;_brew_opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOMEBREW_PREFIX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/opt/homebrew&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/opt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -d &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;_brew_opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/usr/local/opt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/coreutils/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/findutils/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/gnu-sed/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/grep/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/curl/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;unset&lt;/span&gt; _brew_opt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A path-prefix lookup costs nothing. The &lt;code&gt;/usr/local/opt&lt;/code&gt; fallback is there for Intel macOS, where &lt;code&gt;HOMEBREW_PREFIX&lt;/code&gt; may be unset, for example in bash without &lt;code&gt;brew shellenv&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Saves about 180 ms.&lt;/p&gt;&#10;&lt;h2 id="2-defer-plugins-with-zinits-wait-ice"&gt;2. Defer plugins with zinit&amp;rsquo;s &lt;code&gt;wait&lt;/code&gt; ice&lt;/h2&gt;&#10;&lt;p&gt;If you use a plugin manager, this is probably the biggest single win on the table. Syntax highlighting, autosuggestions, history-substring-search, completion add-ons: none of these need to load before the first prompt renders. They only need to be there by the time you type something.&lt;/p&gt;&#10;&lt;p&gt;With &lt;a href="https://github.com/zdharma-continuum/zinit" rel="noopener external"&gt;zinit&lt;/a&gt;&amp;rsquo;s &lt;code&gt;wait&lt;/code&gt; ice, you defer them until after the first prompt:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zinit &lt;span class="nb"&gt;wait&lt;/span&gt; lucid light-mode &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atinit&lt;span class="s2"&gt;&amp;#34;ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zdharma-continuum/fast-syntax-highlighting &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atload&lt;span class="s2"&gt;&amp;#34;_zsh_autosuggest_start&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-autosuggestions &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; blockf atpull&lt;span class="s1"&gt;&amp;#39;zinit creinstall -q .&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-completions &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atload&lt;span class="s2"&gt;&amp;#34;bindkey &amp;#39;^[[A&amp;#39; history-substring-search-up; bindkey &amp;#39;^[[B&amp;#39; history-substring-search-down&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-history-substring-search &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; wfxr/forgit&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;atinit&lt;/code&gt; ice on the first plugin runs &lt;code&gt;compinit&lt;/code&gt; once and replays cached completions via &lt;code&gt;zicdreplay&lt;/code&gt;. One &lt;code&gt;compinit&lt;/code&gt; covers every turbo-loaded plugin. The &lt;code&gt;atload&amp;quot;_zsh_autosuggest_start&amp;quot;&lt;/code&gt; line re-initializes autosuggestions after deferred loading. Without it the plugin silently no-ops, which took me an embarrassing amount of time to work out.&lt;/p&gt;&#10;&lt;p&gt;Saves 100-300 ms.&lt;/p&gt;&#10;&lt;h2 id="3-compile-zcompdump-to-bytecode"&gt;3. Compile &lt;code&gt;.zcompdump&lt;/code&gt; to bytecode&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;compinit&lt;/code&gt; parses thousands of completion functions on every startup. Compiling its dump file to bytecode means subsequent shells skip the parser:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ZDOTDIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.zcompdump&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[[&lt;/span&gt; -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;! -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.zwc&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -nt &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.zwc&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zcompile &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It recompiles only when the source is newer than the cached &lt;code&gt;.zwc&lt;/code&gt;. Saves 30-80 ms.&lt;/p&gt;&#10;&lt;h2 id="4-pick-one-version-manager"&gt;4. Pick one version manager&lt;/h2&gt;&#10;&lt;p&gt;I had both &lt;code&gt;fnm&lt;/code&gt; and &lt;code&gt;mise&lt;/code&gt; active, which is one more than anyone needs. Mise manages everything I actually use, Node, Python, Go; &lt;code&gt;fnm env&lt;/code&gt; in &lt;code&gt;.zshrc&lt;/code&gt; was a leftover that nothing depended on. Deleting it saved another 30-80 ms per shell.&lt;/p&gt;&#10;&lt;p&gt;Mise itself has a second optimization, &lt;code&gt;--shims&lt;/code&gt; mode:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;~/.local/bin/mise activate zsh --shims&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Default &lt;code&gt;mise activate&lt;/code&gt; installs a &lt;code&gt;precmd&lt;/code&gt; hook that runs on every prompt to keep tool versions in sync with &lt;code&gt;.mise.toml&lt;/code&gt;. Shim mode replaces that hook with symlinks on &lt;code&gt;PATH&lt;/code&gt; that resolve the right version the first time you call each tool. That is about 17 ms saved at startup, paid back as 10-30 ms on the first invocation per shell. For interactive use it is a fine trade.&lt;/p&gt;&#10;&lt;h2 id="5-cache-the-slow-evals"&gt;5. Cache the slow &lt;code&gt;eval&lt;/code&gt;s&lt;/h2&gt;&#10;&lt;p&gt;A lot of tools want you to run &lt;code&gt;eval &amp;quot;$(tool init zsh)&amp;quot;&lt;/code&gt; at startup. Each one forks the tool, runs its setup, and pipes the result back. You pay that cost every shell, even though the output rarely changes between runs.&lt;/p&gt;&#10;&lt;p&gt;A small helper fixes it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.zsh/cache/&lt;span class="nv"&gt;$name&lt;/span&gt;.zsh&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[[&lt;/span&gt; -s &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; -nt &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/dev/null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; mkdir -p ~/.zsh/cache&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval zoxide zoxide init zsh --cmd j&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval uv uv generate-shell-completion zsh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;$cache -nt ${commands[$1]:-/dev/null}&lt;/code&gt; test compares mtimes. When brew or mise upgrades the binary, the cache invalidates on its own. No stale-cache surprises after a &lt;code&gt;brew upgrade&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Keep &lt;code&gt;starship init zsh&lt;/code&gt; eager. You need it for the prompt anyway, and it is already fast.&lt;/p&gt;&#10;&lt;p&gt;Saves 50-100 ms.&lt;/p&gt;&#10;&lt;h2 id="6-audit-plugins-cut-what-you-dont-use"&gt;6. Audit plugins; cut what you don&amp;rsquo;t use&lt;/h2&gt;&#10;&lt;p&gt;I was loading the &lt;a href="https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/git" rel="noopener external"&gt;Oh My Zsh git plugin&lt;/a&gt; via zinit&amp;rsquo;s snippet feature. The plugin ships roughly 150 aliases.&lt;/p&gt;&#10;&lt;p&gt;So I grepped my last few months of shell history against its alias list. Five OMZ-only aliases came back: &lt;code&gt;gsw&lt;/code&gt;, &lt;code&gt;glo&lt;/code&gt;, &lt;code&gt;gf&lt;/code&gt;, &lt;code&gt;gclean&lt;/code&gt;, &lt;code&gt;gss&lt;/code&gt;. Everything else I either did not use or had already redefined to my own taste. I copied those five into &lt;code&gt;shell/.aliases&lt;/code&gt; and dropped the &lt;code&gt;OMZP::git&lt;/code&gt; snippet from zinit.&lt;/p&gt;&#10;&lt;p&gt;There is a second reason to do this, and it has nothing to do with startup time. Because the OMZ plugin loaded in turbo mode, after my eager &lt;code&gt;shell/.aliases&lt;/code&gt;, it was quietly overwriting aliases I had defined myself. My &lt;code&gt;gc='git commit -m'&lt;/code&gt; was getting clobbered to &lt;code&gt;git commit --verbose&lt;/code&gt;, and &lt;code&gt;gg='lazygit'&lt;/code&gt; to &lt;code&gt;git gui&lt;/code&gt;. The plugin &amp;ldquo;worked&amp;rdquo; because turbo-loaded aliases land before you type, but the behavior was not the one I had configured. Dropping the plugin put my own aliases back in charge.&lt;/p&gt;&#10;&lt;p&gt;While I was at it I found &lt;code&gt;zsh/.zsh/plugins/git/git.plugin.zsh&lt;/code&gt; in my dotfiles, 150 lines of git aliases, sourced from nowhere. Years of dead code I had been carrying around. Gone.&lt;/p&gt;&#10;&lt;h2 id="7-move-path-and-env-to-zshenv"&gt;7. Move PATH and env to &lt;code&gt;.zshenv&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;.zshrc&lt;/code&gt; runs only for interactive shells. If you set &lt;code&gt;PATH&lt;/code&gt; there, then cron jobs, &lt;code&gt;zsh -c &amp;quot;command&amp;quot;&lt;/code&gt; invocations, and IDE-spawned non-interactive shells do not see your tools. That is how you end up burning half a day on &amp;ldquo;works in my terminal, breaks in CI.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;The right home is &lt;code&gt;.zshenv&lt;/code&gt;, which runs for every zsh invocation:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ~/.zshenv&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PNPM_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/pnpm&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;typeset&lt;/span&gt; -U path PATH &lt;span class="c1"&gt;# auto-dedupe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$PNPM_HOME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cargo/env&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; . &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cargo/env&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;typeset -U path PATH&lt;/code&gt; ties the &lt;code&gt;path&lt;/code&gt; array to the &lt;code&gt;PATH&lt;/code&gt; scalar and deduplicates automatically. No more 8 KB &lt;code&gt;PATH&lt;/code&gt; strings with the same entry six times because nested shells kept prepending.&lt;/p&gt;&#10;&lt;p&gt;This is not a cold-start win, since &lt;code&gt;.zshenv&lt;/code&gt; still runs. It is a correctness fix. A shorter &lt;code&gt;PATH&lt;/code&gt; does make command lookups faster, so you get a little of both.&lt;/p&gt;&#10;&lt;h2 id="8-fix-the-things-you-didnt-know-were-broken"&gt;8. Fix the things you didn&amp;rsquo;t know were broken&lt;/h2&gt;&#10;&lt;p&gt;While I was in there, &lt;code&gt;zprof&lt;/code&gt; and a slow re-read of the config surfaced a stack of small bugs that had nothing to do with cold start. They were just wrong:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;~/.profile&lt;/code&gt; was being sourced twice, once from &lt;code&gt;.zshrc&lt;/code&gt; and once from &lt;code&gt;.zshenv&lt;/code&gt;. 20-80 ms of pure waste.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;compinit&lt;/code&gt;&amp;rsquo;s cache-staleness check had inverted-looking logic and a misleading comment. It happened to do the right thing, but only by accident.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;zsh/.zsh/lib/completions.zsh&lt;/code&gt; referenced &lt;code&gt;$ZSH&lt;/code&gt;, an Oh My Zsh leftover that was unset in my setup. The completion cache-path was silently expanding to &lt;code&gt;/cache/&lt;/code&gt;, the filesystem root. Nothing got written there because the directory was not writable, so completions had no cache at all. Caching had quietly been off the whole time.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;compdef&lt;/code&gt; calls from eager tool initializations were arriving &lt;em&gt;before&lt;/em&gt; turbo-mode &lt;code&gt;compinit&lt;/code&gt; had run, so completions for those tools were not registering. I fixed it with a small shim that queues the calls until &lt;code&gt;compinit&lt;/code&gt; is ready:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; ! &lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="p"&gt;+functions[compdef]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;typeset&lt;/span&gt; -ga __deferred_compdefs&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; compdef&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="o"&gt;+=(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="p"&gt;(j: :)@&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then in the turbo block&amp;rsquo;s &lt;code&gt;atinit&lt;/code&gt;, after &lt;code&gt;zicompinit; zicdreplay&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; c in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;compdef &lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;unset&lt;/span&gt; __deferred_compdefs&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;None of those moved the startup number. They were still bugs, and now they are not.&lt;/p&gt;&#10;&lt;h2 id="doing-this-on-your-own-config"&gt;Doing this on your own config&lt;/h2&gt;&#10;&lt;p&gt;Measure before you touch anything. &lt;code&gt;time zsh -i -c exit&lt;/code&gt; for cold start, &lt;code&gt;zprof&lt;/code&gt; for the in-shell breakdown. Skip that step and you will spend an afternoon optimizing the wrong thing. I have done exactly that.&lt;/p&gt;&#10;&lt;p&gt;Then grep your shell history against the alias and function lists of whatever plugins you load. You will usually find you are loading hundreds of definitions to use a handful.&lt;/p&gt;&#10;&lt;p&gt;Defer what can be deferred. First-prompt latency is what you feel, and almost nothing actually has to load before you can type. Cache expensive &lt;code&gt;eval&lt;/code&gt;s whose output only changes when the binary changes, and key the cache on the binary&amp;rsquo;s mtime so upgrades invalidate it for you. Hardcode the constants: &lt;code&gt;brew --prefix coreutils&lt;/code&gt; is always &lt;code&gt;${HOMEBREW_PREFIX}/opt/coreutils&lt;/code&gt;. Forking a Ruby program to ask is silly.&lt;/p&gt;&#10;&lt;p&gt;And keep environment variables in &lt;code&gt;.zshenv&lt;/code&gt;, not &lt;code&gt;.zshrc&lt;/code&gt;. Future-you, debugging why something works in iTerm but not in a cron job, will be grateful.&lt;/p&gt;&#10;&lt;h2 id="estimated-total"&gt;Estimated total&lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Change&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Savings&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stop forking &lt;code&gt;brew --prefix&lt;/code&gt; × 5&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;~180 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Defer plugins with &lt;code&gt;zinit wait&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100-300 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Cache remaining &lt;code&gt;eval&lt;/code&gt;s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;50-100 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Compile &lt;code&gt;.zcompdump&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30-80 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Drop &lt;code&gt;fnm&lt;/code&gt; (mise covers it)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30-80 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;~400-700 ms&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;These are estimates, not measurements. Your actual numbers depend on your hardware, your plugin set, and how much cruft you had accumulated over the years. Under 200 ms on Apple Silicon is doable with stock zsh and a small, well-deferred plugin set.&lt;/p&gt;&#10;&lt;p&gt;My dotfiles, with all of this applied, are at &lt;a href="https://github.com/theskumar/dotfiles" rel="noopener external"&gt;github.com/theskumar/dotfiles&lt;/a&gt;. The &lt;code&gt;zsh/OPTIMIZATION.md&lt;/code&gt; file there is the running log of what is done and what is still open.&lt;/p&gt;&#10;&lt;p&gt;I plan to repeat the audit as the config changes.&lt;/p&gt;&#10;</description></item><item><title>moor</title><link>https://saurabh-kumar.com/til/1774094400/</link><pubDate>Sat, 21 Mar 2026 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1774094400/</guid><description>&lt;p&gt;&lt;a href="https://github.com/walles/moor" rel="noopener external"&gt;&lt;code&gt;moor&lt;/code&gt;&lt;/a&gt; is a better replacement for less and bat as PAGER.&lt;/p&gt;&#10;</description></item><item><title>git-trim</title><link>https://saurabh-kumar.com/til/1771588800/</link><pubDate>Fri, 20 Feb 2026 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1771588800/</guid><description>&lt;p&gt;&lt;code&gt;brew install git-trim&lt;/code&gt; &lt;a href="https://github.com/foriequal0/git-trim" rel="noopener external"&gt;git-trim&lt;/a&gt; - A dedicated binary for&#10;safely deleting merged branches. Handles edge cases better than bash one-liners, including squash-merged branches. &lt;a href="https://news.ycombinator.com/item?id=47089533" rel="noopener external"&gt;#&lt;/a&gt;&lt;/p&gt;&#10;</description></item></channel></rss>