← blog

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:

bash
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

fish
set -l output (mole completion fish 2>/dev/null); and echo "$output" | source

After: Generate once, load from file

bash
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

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

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

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

~/.config/fish/functions/clear.fish:

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:

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/bin was added twice)
  • Converted redundant aliases to abbreviations (abbreviations show in history)
  • Removed tmp alias since mkcd with 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:

fish
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

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