← blog

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:

bash
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:

fish
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:

bash
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:

fish
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:

fish
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:

text
~/.config/fish/functions/
├── clear.fish
├── fish_greeting.fish
├── gitignore.fish
├── ls.fish
└── mkcd.fish

example:

fish
function ls --wraps eza --description "List files with eza"
    eza -laH --icons --git $argv
end

and the stupid-but-important one:

fish
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/bin was added twice)
  • converted some aliases to abbreviations so history shows the real command
  • removed tmp because mkcd with no args already covered that use case

example abbreviations:

fish
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:

text
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:

text
~/.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.