Shell startup files, command wrappers, hooks, and small CLIs tend to become slow one reasonable addition at a time. A version manager scans its installation, a health check verifies live state, a parser is invoked once per field, and optional integrations initialize before anyone asks for them. None looks expensive alone, but every cost sits between invocation and the first useful action.
The governing principle is simple:
Freshness does not require blocking readiness.
Treat time to ready and time to fully settled as separate latency targets. Keep true prerequisites on the synchronous path, start freshness work asynchronously when its result may arrive later, load optional capabilities on first use, and move unrelated maintenance out of startup entirely. Then optimize all of those paths anyway, because deferred work still consumes resources and affects tail latency.
"Startup finished" is too vague to design or measure. Name the first useful event instead: the prompt accepts input, the wrapper begins streaming its result, the hook releases the parent process, or the requested subcommand starts doing its own work.
For each startup operation, ask whether that event depends on its result.
| Work type | Scheduling choice | Typical examples |
|---|---|---|
| Required for correctness now | Synchronous hot path | Configuration needed by the next operation, required authentication, input validation |
| Must run every invocation, result may arrive later | Eager asynchronous | Health checks, drift detection, advisory diagnostics |
| Needed only when a feature is used | Lazy | Version managers, completion systems, optional SDKs and plugins |
| Independent of this invocation | Periodic process or daemon | Cleanup, update scans, telemetry aggregation |
| Stable until a known input changes | Cache with explicit invalidation | Deterministic discovery or generated metadata |
Use this decision sequence:
Does readiness depend on the result?
yes -> run synchronously
no -> must it run for every invocation?
yes -> start it asynchronously
no -> is it needed when a feature is first used?
yes -> load it lazily
no -> schedule it independently
Caching is not a synonym for deferral. If a check must observe live state on every invocation, keep that frequency and change only the waiting relationship. An asynchronous check can still be fresh every time.
Moving an operation off the hot path improves readiness latency, but it does not make the operation free. It can still compete with foreground work for CPU and I/O, delay complete settlement, consume battery, or multiply across many concurrent shells.
Shell performance is often process performance. Before reaching for a cache, look for avoidable process boundaries and repeated scans:
Batching must preserve error semantics. One combined parser invocation is only equivalent when malformed input, missing fields, defaults, and exit statuses still behave the same way as the separate calls.
A startup check is a good async candidate when all of the following are true:
Do not let a background process write directly into an interactive terminal while the user may be typing. Capture each invocation's output separately, mark completion atomically, and flush the captured output from a prompt hook or another UI-owned boundary.
This Bash 3.2-compatible sketch shows the core lifecycle. It intentionally uses a unique private directory for each invocation and a completion file rather than a shared result cache:
_startup_check_poll() {
local directory="${STARTUP_CHECK_DIRECTORY:-}"
[ -n "$directory" ] || return 0
[ -f "$directory/done" ] || return 0
local status
IFS= read -r status < "$directory/status" || status=1
[ ! -s "$directory/output" ] || cat "$directory/output"
case "$status" in
0) ;;
*) printf '%s\n' "startup check exited with status $status" >&2 ;;
esac
rm -f "$directory/output" "$directory/status" "$directory/done"
rmdir "$directory" 2>/dev/null || :
unset STARTUP_CHECK_DIRECTORY STARTUP_CHECK_PID
}
_startup_check_begin() {
local check="$1"
local directory; directory="$(mktemp -d "${TMPDIR:-/tmp}/startup-check.XXXXXX")" || {
"$check" || :
return 0
}
STARTUP_CHECK_DIRECTORY="$directory"
# The command-substitution shell owns the background job so interactive
# Bash does not print a job-control banner before the first prompt
STARTUP_CHECK_PID="$(
(
umask 077
trap ': > "$directory/done"' EXIT
status=0
"$check" > "$directory/output" 2>&1 || status=$?
printf '%s\n' "$status" > "$directory/status"
) </dev/null >/dev/null 2>&1 &
printf '%s\n' "$!"
)"
}
The command-substitution layer is load-bearing. A direct (...) & launched by an interactive shell may print a job number and PID even if it is immediately disowned. Moving ownership into the short-lived subshell prevents that banner. Because the interactive shell is no longer the worker's direct parent, poll the completion marker instead of relying on wait.
Call _startup_check_poll from the shell's existing prompt hook. Preserve any hook already installed, avoid adding the poller twice when startup files are re-sourced, and account for both scalar and array forms if the supported Bash versions include both. A prompt hook should only inspect completed state; it must never block waiting for the worker.
Async behavior needs a deterministic escape hatch for debugging, tests, automation, and environments where background execution is unavailable:
_run_startup_check() {
local check="$1"
if [ "${STARTUP_CHECKS_ASYNC:-1}" != 0 ]; then
case "$-" in
*i*) _startup_check_begin "$check"; return 0 ;;
esac
fi
local status=0
"$check" || status=$?
case "$status" in
0) ;;
*) printf '%s\n' "startup check exited with status $status" >&2 ;;
esac
}
The override should restore the real synchronous operation, not a mock, cached answer, or reduced check. Non-interactive shells should normally use the synchronous path unless their caller explicitly participates in the async result protocol.
Re-sourcing startup files or starting several shells at once creates concurrency that the old synchronous design may never have seen. Pick and document one behavior:
Never let an accidental global temp filename choose the policy for you. Unique state directories, private permissions, an EXIT completion trap, and cleanup after delivery make the lifecycle explicit. Also decide what happens if the parent shell exits before polling; ephemeral state may be left for operating-system cleanup, removed by an exit hook, or expired by a separate maintenance policy.
Async work can slow the hot path by competing with it. Start I/O-bound checks early when they can spend most of their lifetime waiting. Consider starting CPU-heavy checks at readiness, running them with lower priority, or moving them to an independent scheduler. Measure under realistic concurrency instead of assuming that & removed the cost.
Lazy initialization is better than eager async when startup does not need the work at all unless a particular command is used. A self-replacing function can preserve the normal command name while loading its implementation on first call:
heavy_tool() {
unset -f heavy_tool
if ! . "$HOME/.heavy-tool/init.sh"; then
printf '%s\n' "heavy_tool: initialization failed" >&2
return 1
fi
if ! command -v heavy_tool >/dev/null 2>&1; then
printf '%s\n' "heavy_tool: initializer did not define heavy_tool" >&2
return 1
fi
heavy_tool "$@"
}
This pattern is appropriate only when initialization has no side effect that every shell requires before other commands run. Preserve arguments and exit status, make failure visible, and verify that the initializer actually replaced the wrapper before recursing.
Completions can often be lazier still: load a completion definition when the command is first completed rather than when the shell starts. The exact mechanism differs by shell, but the dependency rule stays the same.
An internal timer around one function answers a different question from an external timer around the user's actual invocation. A useful benchmark records at least three values:
Use the same executable, environment, startup mode, working directory, and input shape the user actually experiences. Run warmups before measured iterations, prevent background workers from overlapping the next iteration, and report the iteration count with mean, median, minimum, and maximum. Compare distributions, not a single lucky launch.
Instrument major boundaries with a cheap shell builtin when possible. Full xtrace output can distort a profile by adding work to every command, especially in files containing many function definitions. Category timings should add back to the externally observed total; if they do not, name the unmeasured region rather than renormalizing it away.
After an async change, benchmark the optimized implementation synchronously as well. That separates two independent wins:
Both matter. The synchronous number protects automation and the fallback path, while settlement time reveals whether a faster prompt merely hid a growing resource cost.
Fast startup is not the absence of checks. It is a clear readiness contract, inexpensive implementation, deliberate scheduling, and reliable delivery of everything that finishes later.