May 23, 2026
fish startup: 160ms -> 24ms by deleting startup work
profiling my fish shell startup and removing the stuff that had no business running every time a shell opened
my fish shell was taking around 160ms to start.
not terrible in absolute terms, but annoying enough to notice. dash on the same machine was starting in ~3-4ms and a barebones zsh config was around 8ms. fish was obviously never going to be dash, but 160ms for opening a shell felt like something was wrong.
turns out the problem was simple: i was doing too much work at startup.
profiling first
fish has built-in startup profiling, which is nice:
fish --profile-startup /tmp/fish_startup.txt -c 'exit'
sort -nrk2 /tmp/fish_startup.txt | head -30
the bad parts showed up immediately:
| time | cause |
|---|---|
| ~85ms | generating mole completions at runtime |
| ~24ms | calling brew shellenv every startup |
| ~15ms | calling brew --prefix for completions |
| ~10ms | theme / plugin loading |
none of this was mysterious. i had just let tiny conveniences accumulate until every new shell was paying the tax.
fix 1: stop generating completions every time
this was the biggest win.
before:
set -l output (mole completion fish 2>/dev/null); and echo "$output" | source
that is insane in hindsight. generating completions on every shell startup just to get a static completion file.
after:
mole completion fish > ~/.config/fish/completions/mole.fish
then remove the runtime generation from config.fish.
result: ~85ms gone.
fix 2: stop asking Homebrew where Homebrew is
this was the other obvious one.
before:
eval "$(/opt/homebrew/bin/brew shellenv)"
if type -q brew
set -p fish_complete_path (brew --prefix)/share/fish/vendor_completions.d
end
brew shellenv and brew --prefix are useful when setting things up. they do not need to run every time i open a shell.
BUT, im not juggling my brew paths around.
after:
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
set -p fish_complete_path /opt/homebrew/share/fish/vendor_completions.d
roughly another ~40ms gone.
fix 3: move functions out of config.fish
functions inside config.fish are parsed on every startup. functions in ~/.config/fish/functions/ are lazy-loaded when called.
so stuff like ls, clear, mkcd, etc. moved into separate files:
~/.config/fish/functions/
├── clear.fish
├── fish_greeting.fish
├── gitignore.fish
├── ls.fish
└── mkcd.fish
example:
function ls --wraps eza --description "List files with eza"
eza -laH --icons --git $argv
end
and the stupid-but-important one:
function clear --wraps clear --description "Clear screen and show a Pokémon"
command clear
pokeget random --hide-name
end
yes, pokemon stays. startup speed is important but not at the cost of joy lol.
fix 4: clean the little stuff
there were smaller cleanup wins too:
- removed duplicate PATH entries (
~/.local/binwas added twice) - converted some aliases to abbreviations so history shows the real command
- removed
tmpbecausemkcdwith no args already covered that use case
example abbreviations:
abbr --add ga "git add"
abbr --add gc "git commit"
abbr --add rm trash
abbr --add rrm "command rm"
abbr --add .. "cd .."
result
warm startup went from 160ms -> 24ms.
intermediate numbers:
| change | startup time |
|---|---|
| original config | 160ms |
| cached completions + hardcoded brew | 58ms |
functions moved out of config.fish |
40ms |
| warm run after cleanup | 24ms |
that is comfortably out of the annoying range.
what mattered
nothing clever happened here. the pattern was just:
profile -> find runtime work -> move it out of startup
most shell slowness comes from doing things at startup that could have been done once, cached, or lazy-loaded. generated completions should be files. stable paths should be hardcoded. functions should autoload. shell startup should not be a build step.
i still prefer keeping the config explicit instead of hiding everything in universal variables. a few extra milliseconds are fine if future-me can read the file and understand what is happening.
final structure:
~/.config/fish/
├── config.fish
├── fish_plugins
├── completions/
│ └── mole.fish
└── functions/
├── clear.fish
├── fish_greeting.fish
├── gitignore.fish
├── ls.fish
└── mkcd.fish
current config.fish is around 75 lines. fast enough, still readable, Pokémon intact.