a loading animation as a background process with clean cleanup
A spinner shows that a script is active even when the duration of a step is unknown and no percentage can be calculated. This article shows how a spinner animation emerges as a background process in Bash, how it is synchronized cleanly with a running command, and how trap ensures no spinner process is left orphaned.
Table of Contents
- 1. What a spinner stands for when no percentage is possible
- 2. Core principle: character sequence, carriage return and a loop
- 3. The spinner as its own background process
- 4. Synchronization: starting and stopping the spinner
- 5. Clean cleanup with trap instead of orphaned processes
- 6. Forwarding the exit code of the actual command
- 7. Different character sets and speeds
- 8. Integration into existing deployment scripts
- 9. Spinner compared to progress bars and silent waiting
- 10. Summary
- 11. FAQ
1. What a spinner stands for when no percentage is possible
A spinner solves a different problem than a progress bar: it is used precisely when the total duration of an operation is unknown and therefore no percentage can be calculated. A DNS lookup, an API call with unknown response time, or a database backup of variable size are typical candidates where a spinner is the only sensible display form. Without any visual feedback, a script feels crashed in such moments, even if everything proceeds normally in the background.
The psychological effect of a spinner is simple but effective: a moving animation signals to the user that the process is running, even without concrete information about the remaining time. Unlike a progress bar, a spinner makes no statement about actual progress, only about the fact that something is happening. This article shows how such a spinner is technically implemented as an independent background process without blocking the actual command.
2. Core principle: character sequence, carriage return and a loop
The core principle of a spinner resembles that of a progress bar: both use the carriage return character \r to repeatedly overwrite the same line. The difference is in the content: instead of a growing bar, a spinner rotates through a short character sequence, typically | / - \ for a classic ASCII spinner or Braille characters like ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ for a smoother, more modern look.
A simple infinite loop with a short pause between characters already produces the animation. The decisive difference from all patterns shown so far, though, is that a spinner does not run synchronously with a processing loop, but in parallel to a single, potentially long-running command whose progress cannot be measured from the outside. That is exactly what makes the background process implementation necessary, instead of integrating the animation directly into the main flow of the script.
#!/usr/bin/env bash
set -euo pipefail
# Minimal spinner loop — demonstrates the animation principle only
spinner_chars="/-\|"
for i in {1..20}; do
char="${spinner_chars:i%4:1}"
printf "\r%s Processing..." "$char"
sleep 0.1
done
printf "\r"
3. The spinner as its own background process
For a spinner to animate in parallel with a long-running command, the animation loop must run in its own background process, started with the & operator. The main script starts the spinner in the background, then runs the actual command in the foreground, and terminates the spinner process once the command is done. The process ID of the background process is captured via the special variable $! so it can be terminated specifically later.
A common mistake in a first implementation is letting the spinner process run in the same shell session without truly decoupling it. Output from the spinner and the actual command can then overlap if the command itself also writes to stdout. In practice, the spinner therefore works best for commands that do not produce their own output during execution, for example curl -s, tar, or database dumps with suppressed standard output.
#!/usr/bin/env bash
set -euo pipefail
start_spinner() {
local spinner_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
while true; do
printf "\r%s Processing..." "${spinner_chars:i++%${#spinner_chars}:1}"
sleep 0.1
done
}
# Start the spinner as a detached background process
start_spinner &
spinner_pid=$!
# The actual long-running command
sleep 3
# Stop the spinner once the command is done
kill "$spinner_pid" 2>/dev/null
wait "$spinner_pid" 2>/dev/null
printf "\r\033[K" # clear the line
echo "Done."
4. Synchronization: starting and stopping the spinner
The central challenge with a spinner background process is reliable synchronization with the actual command. If the spinner starts too early or is stopped too late, visual artifacts appear, such as a spinner still running after the actual command has finished. The proven flow is always the same: start the spinner in the background, save the PID in a variable, run the actual command in the foreground and capture its exit code separately, then terminate the spinner process with kill.
A subtle but important point is the order between kill and wait. Without wait after the kill call, the script may continue before the spinner process has actually terminated, which can result in one final visible spinner output overwriting the subsequent script output. The explicit wait ensures the process is fully terminated before the script proceeds with the next output.
5. Clean cleanup with trap instead of orphaned processes
The biggest practical mistake with a self built spinner is an orphaned background process that keeps running when the main script aborts unexpectedly, for example via Ctrl-C or an error under set -e. Without safeguards, the spinner process does not terminate in this case, but keeps running in an infinite loop in the background until manually killed. With repeated runs of the script, several orphaned spinner processes accumulate over time, unnecessarily consuming CPU cycles.
The solution is a trap on EXIT, INT and TERM that reliably terminates the spinner process regardless of the reason for termination. This trap should be registered as early as possible, right after starting the spinner process, so that even an abort immediately after startup is handled correctly. The combination of trap and a guard check whether the PID still exists at all makes the cleanup robust against every conceivable abort scenario.
#!/usr/bin/env bash
set -euo pipefail
spinner_pid=""
stop_spinner() {
if [[ -n "$spinner_pid" ]] && kill -0 "$spinner_pid" 2>/dev/null; then
kill "$spinner_pid" 2>/dev/null
wait "$spinner_pid" 2>/dev/null
fi
printf "\r\033[K" # clear any leftover spinner output
}
# Register cleanup for every possible exit path
trap stop_spinner EXIT INT TERM
start_spinner() {
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
while true; do
printf "\r%s Backing up..." "${chars:i++%${#chars}:1}"
sleep 0.1
done
}
start_spinner &
spinner_pid=$!
# Simulate a long-running command that might fail or be interrupted
sleep 3
echo "Backup complete."
6. Forwarding the exit code of the actual command
A frequently overlooked aspect is that the exit code of the actual command must be preserved, even if further actions happen between command execution and script end, for example stopping the spinner. If you call the actual command directly, followed by the kill call for the spinner, the exit code of the kill command overwrites the original exit code unless it is explicitly cached.
The correct approach captures the exit code of the actual command in its own variable immediately after execution, before any further command runs. Only then is the spinner stopped, and at the end of the script it exits explicitly with exit "$command_exit_code". This pattern ensures that a calling script or a CI pipeline correctly detects whether the actual command succeeded, regardless of the internal flow of spinner management.
#!/usr/bin/env bash
set -uo pipefail # note: no -e here, we handle the exit code manually
run_with_spinner() {
local message="$1"
shift
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
( while true; do
printf "\r%s %s" "${chars:i++%${#chars}:1}" "$message"
sleep 0.1
done ) &
local spinner_pid=$!
# Run the actual command, capture its exit code without losing it
"$@"
local command_exit_code=$?
kill "$spinner_pid" 2>/dev/null
wait "$spinner_pid" 2>/dev/null
printf "\r\033[K"
return "$command_exit_code"
}
run_with_spinner "Backing up database..." pg_dump mydb > backup.sql
echo "Backup exit code: $?"
7. Different character sets and speeds
The choice of character set significantly affects how smooth a spinner appears. The classic ASCII spinner with | / - \ works in every terminal, even in very old or restricted environments without UTF-8 support. The Braille characters offer a noticeably smoother look with ten instead of four intermediate steps, but require UTF-8-capable terminals, which is practically always the case in modern environments, though not guaranteed in very old SSH sessions.
The rotation speed, controlled via the sleep value between characters, should strike a balance between visible motion and CPU load. A sleep value that is too short, around 0.01 seconds, creates noticeable CPU load from the many printf calls, while a value over 0.3 seconds feels choppy instead of smooth. Values between 0.08 and 0.15 seconds have proven to be a good middle ground in practice, appearing smooth on most systems without consuming unnecessary resources.
8. Integration into existing deployment scripts
The practical value of a spinner shows most clearly in deployment scripts that run several long-running steps in sequence, for example installing dependencies, compiling assets and clearing cache. Instead of instrumenting each step individually with its own spinner, a central wrapper function like the one shown in the previous section is recommended, reusable for any command without duplicating the spinner logic multiple times.
For scripts with several consecutive steps, it is also worth adding a short success message after each step that replaces the spinner with a checkmark or a simple "[OK]". This combination of animation during execution and a clear confirmation afterward gives the user a reliable visual signal both during and after each step, without producing multiple lines of output per step.
9. Spinner compared to progress bars and silent waiting
The choice between a spinner, a progress bar and no display at all primarily depends on whether the progress of an operation can be measured. If the total duration or total amount is known, a progress bar is the more informative choice. If it is unknown, the spinner remains the only sensible option that still gives active feedback.
| Situation | Progress measurable? | Recommended display | Reason |
|---|---|---|---|
| Processing N files | Yes | Progress bar with percent | Total amount and current status known |
| Single API call | No | Spinner | Response time unknown, no percentage possible |
| Very short command (< 1s) | Doesn't matter | No display | Spinner would just flicker, no benefit |
| CI pipeline log | Doesn't matter | Plain text lines instead of animation | Carriage return does not render cleanly in logs |
In practice a small heuristic pays off: commands whose typical runtime is under one second should not get a spinner, because the animation would only flicker briefly and cause more distraction than help. For anything longer without a known total duration, the spinner is the right tool, combined with the wrapper function shown in section 8 for consistent use throughout the entire script.
Mironsoft
Shell automation and CLI tooling for development teams
Scripts without feedback during long commands?
We build spinner animations with clean process cleanup into your deployment and maintenance scripts, without orphaned background processes and with correctly forwarded exit codes.
Spinner integration
Implementing loading animations for commands with unknown runtime
Process hygiene
Trap-based cleanup against orphaned background processes
Deployment tooling
Consistent wrapper functions for every step in the deployment
10. Summary
A spinner animation in Bash is based on an infinite loop that repeatedly writes a short character sequence into the same line via carriage return, started as an independent background process. The real challenge is not the animation itself but reliable management: capture the PID with $!, terminate the spinner after the actual command finishes with kill and wait, and use trap on EXIT, INT and TERM to ensure no process is left orphaned.
Equally important is correctly forwarding the exit code of the actual command, so a calling script or CI pipeline reliably detects success and failure, independent of the internal spinner management. Once these patterns are encapsulated in a reusable wrapper function, any long-running command in the script can be given a consistent spinner animation without rewriting the logic for every call.
Spinner animations in Bash — The essentials at a glance
Core principle
Infinite loop with rotating character sequence, printf "\r" without newline, started as a background process with &.
Termination
Capture PID with $!, terminate with kill, wait for actual termination with wait.
Cleanup
trap stop_spinner EXIT INT TERM prevents orphaned processes in every abort scenario.
Exit code
Capture the exit code of the actual command immediately, before stopping the spinner, then return it explicitly.