May 22, 2026
fish startup
160ms to 24ms
A guide to profiling and optimizing fish shell startup time.
The Problem
My fish shell was taking 160ms to start. On this machine, with a barebones zsh config for comparison:
- zsh: 8ms
- dash: 3-4ms
While 160ms isn't terrible, it's noticeably sluggish when spawning new shells frequently.
Profiling
Fish has built-in startup profiling:
fish --profile-startup /tmp/fish_startup.txt -c 'exit'
sort -nrk2 /tmp/fish_startup.txt | head -30
This revealed the culprits:
| Time (ms) | Cause |
|---|---|
| 85ms | mole completion fish — generating completions at runtime |
| 24ms | brew shellenv — calling brew on every startup |
| 15ms | brew --prefix — another brew call |
| ~10ms | Theme loading |
The Fixes
1. Cache Shell Completions (saved ~85ms)
Before: Generating completions every startup
set -l output (mole completion fish 2>/dev/null); and echo "$output" | source
After: Generate once, load from file
mole completion fish > ~/.config/fish/completions/mole.fish
Then remove the runtime generation from config.fish.
2. Hardcode Homebrew Paths (saved ~40ms)
Before: Calling brew on every startup
eval "$(/opt/homebrew/bin/brew shellenv)"
if type -q brew
set -p fish_complete_path (brew --prefix)/share/fish/vendor_completions.d
end
After: Hardcode the paths (they never change)
# Homebrew environment (hardcoded for faster startup)
set -gx HOMEBREW_PREFIX /opt/homebrew
set -gx HOMEBREW_CELLAR /opt/homebrew/Cellar
set -gx HOMEBREW_REPOSITORY /opt/homebrew
fish_add_path -gP /opt/homebrew/bin /opt/homebrew/sbin
# Homebrew completions
set -p fish_complete_path /opt/homebrew/share/fish/vendor_completions.d
3. Move Functions to ~/.config/fish/functions/ (lazy loading)
Functions in config.fish are parsed on every startup. Functions in ~/.config/fish/functions/ are autoloaded only when called.
Moved these to separate files:
~/.config/fish/functions/ls.fish:
function ls --wraps eza --description "List files with eza"
eza -laH --icons --git $argv
end
~/.config/fish/functions/clear.fish:
function clear --wraps clear --description "Clear screen and show a Pokémon"
command clear
pokeget random --hide-name
end
~/.config/fish/functions/mkcd.fish:
function mkcd --description "Make and enter a directory"
if test (count $argv) -ne 1
cd $(mktemp -d)
else
mkdir $argv[1]
and cd $argv[1]
end
end
4. Clean Up Redundancies
- Removed duplicate PATH entries (
~/.local/binwas added twice) - Converted redundant aliases to abbreviations (abbreviations show in history)
- Removed
tmpalias sincemkcdwith no args does the same thing
5. Use Abbreviations Over Aliases
Abbreviations expand when you press space/enter, so you see the actual command in your history:
abbr --add ga "git add"
abbr --add gc "git commit"
abbr --add rm trash
abbr --add rrm "command rm" # bypass the trash alias
abbr --add .. "cd .."
result
The final warm startup was 24ms, down from 160ms.
Most of the win came from removing work that should not have been happening at shell startup: generating completions, asking Homebrew for paths, and parsing functions I might not call.
The intermediate numbers were useful while debugging:
| Change | Startup time |
|---|---|
| original config | 160ms |
| cached completions + hardcoded brew | 58ms |
functions moved out of config.fish |
40ms |
| warm run after cleanup | 24ms |
That still keeps the parts I actually care about: Rosé Pine Auto, pure-fish/pure, completions, and the silly Pokémon clear screen.
For context, on this same machine, dash starts in about 3-4ms and my barebones zsh starts in about 8ms. Fish is still heavier, but 24ms is comfortably out of the annoying range.
what mattered
The pattern was simple: profile startup, then delete runtime work from the startup path.
The biggest fixes were caching generated completions, hardcoding stable Homebrew paths, and letting fish autoload functions from ~/.config/fish/functions/ instead of parsing them every time.
I still prefer keeping most of this in explicit config rather than hiding everything in universal variables. A few extra milliseconds is worth it if the setup remains obvious when I come back to it later.
Final Config Structure
~/.config/fish/
├── config.fish # Main config (env vars, abbreviations, settings)
├── fish_plugins # Fisher plugins
├── completions/
│ └── mole.fish # Cached completions
└── functions/
├── clear.fish
├── fish_greeting.fish
├── gitignore.fish
├── ls.fish
└── mkcd.fish
The full config.fish after optimization is ~75 lines of clean, declarative configuration.