I built my first countdown timer script to remind myself to stand up and stretch every 25 minutes while working through a big refactor. It sounds trivial, but a well-built countdown timer that runs right in the terminal ended up being one of the small tools I use almost every day. In this article I’ll show you how to build one, starting from the simplest version and working up to a full Pomodoro-style timer with notifications.
The Core Idea
A countdown timer is essentially the mirror image of a stopwatch: instead of counting up from zero, you start with a target duration and count down to zero, then trigger some kind of alert. The building blocks are simple — a loop, arithmetic, and sleep — but there are a few details worth getting right so the timer feels smooth and accurate.
Step 1: A Basic Countdown Timer
Save this as countdown.sh:
#!/usr/bin/env bash
SECONDS_LEFT="${1:-60}"
while [ "$SECONDS_LEFT" -gt 0 ]; do
echo -ne "Time remaining: ${SECONDS_LEFT}s\r"
sleep 1
SECONDS_LEFT=$((SECONDS_LEFT - 1))
done
echo -e "\nTime's up!"
Run it with a duration in seconds:
chmod +x countdown.sh
./countdown.sh 30
If you don’t pass an argument, it defaults to 60 seconds thanks to ${1:-60}.
Explaining the Basic Script
${1:-60}is Bash parameter expansion with a default value: if the first argument ($1) is unset or empty, it falls back to60.- The
while [ "$SECONDS_LEFT" -gt 0 ]loop continues as long as there’s time left. echo -ne "...\r"overwrites the same terminal line each second instead of printing a new line every time, using the same carriage-return trick from the stopwatch article.SECONDS_LEFT=$((SECONDS_LEFT - 1))decrements the counter using Bash arithmetic expansion.- Once the loop exits, a final message prints on a new line (
\nin theecho -e).
Step 2: Formatting as MM:SS
For anything longer than a minute or two, raw seconds are hard to read at a glance:
#!/usr/bin/env bash
TOTAL_SECONDS="${1:-300}"
format_time() {
local total=$1
printf "%02d:%02d" $((total / 60)) $((total % 60))
}
while [ "$TOTAL_SECONDS" -gt 0 ]; do
echo -ne "Time remaining: $(format_time $TOTAL_SECONDS)\r"
sleep 1
TOTAL_SECONDS=$((TOTAL_SECONDS - 1))
done
echo -e "\nTime's up!"
Run it for a 5-minute countdown:
./countdown.sh 300
Step 3: Accepting Human-Readable Input
Typing raw seconds is inconvenient for longer durations. I like accepting input like 5m or 1h30m:
#!/usr/bin/env bash
parse_duration() {
local input="$1"
local total=0
local hours minutes seconds
if [[ "$input" =~ ([0-9]+)h ]]; then
hours="${BASH_REMATCH[1]}"
total=$((total + hours * 3600))
fi
if [[ "$input" =~ ([0-9]+)m ]]; then
minutes="${BASH_REMATCH[1]}"
total=$((total + minutes * 60))
fi
if [[ "$input" =~ ([0-9]+)s ]]; then
seconds="${BASH_REMATCH[1]}"
total=$((total + seconds))
fi
if [[ "$total" -eq 0 && "$input" =~ ^[0-9]+$ ]]; then
total="$input"
fi
echo "$total"
}
format_time() {
local total=$1
printf "%02d:%02d:%02d" $((total / 3600)) $(((total % 3600) / 60)) $((total % 60))
}
TOTAL_SECONDS=$(parse_duration "${1:-5m}")
while [ "$TOTAL_SECONDS" -gt 0 ]; do
echo -ne "Time remaining: $(format_time $TOTAL_SECONDS)\r"
sleep 1
TOTAL_SECONDS=$((TOTAL_SECONDS - 1))
done
echo -e "\nTime's up!"
Now you can run:
./countdown.sh 1h30m
./countdown.sh 25m
./countdown.sh 45s
How the Duration Parsing Works
This is the part I find most interesting to explain. [[ "$input" =~ ([0-9]+)h ]] uses Bash’s regex matching operator =~ to check whether the input contains a number immediately followed by h. If it matches, the captured group (the digits) is available in the special array BASH_REMATCH, where index 1 holds the first captured group. I repeat this check for hours, minutes, and seconds independently, accumulating the total in a single variable. The final fallback handles plain numeric input (like 30) with no suffix at all, treating it as raw seconds for backward compatibility with the earlier version of the script.
Step 4: Adding an Audible and Visual Alert
A silent countdown that finishes while you’re not looking at the terminal isn’t very useful. Let’s add an alert:
#!/usr/bin/env bash
TOTAL_SECONDS="${1:-300}"
format_time() {
printf "%02d:%02d" $(($1 / 60)) $(($1 % 60))
}
while [ "$TOTAL_SECONDS" -gt 0 ]; do
echo -ne "Time remaining: $(format_time $TOTAL_SECONDS)\r"
sleep 1
TOTAL_SECONDS=$((TOTAL_SECONDS - 1))
done
echo -e "\nTime's up!"
# Terminal bell, repeated 3 times
for i in {1..3}; do
echo -ne "\a"
sleep 0.3
done
# Optional desktop notification (Linux with notify-send installed)
if command -v notify-send &> /dev/null; then
notify-send "Countdown Timer" "Time's up!"
fi
The \a escape sequence triggers the terminal bell, which most terminal emulators play as an audible beep or visual flash. The command -v notify-send &> /dev/null check verifies whether the notify-send utility is available before trying to use it, so the script doesn’t error out on systems without a desktop notification daemon.
Step 5: A Pomodoro-Style Work/Break Timer
Once the basic countdown worked reliably, I extended it into a simple Pomodoro timer, cycling between work and break periods:
#!/usr/bin/env bash
WORK_MIN="${1:-25}"
BREAK_MIN="${2:-5}"
CYCLES="${3:-4}"
countdown() {
local seconds=$1
local label=$2
while [ "$seconds" -gt 0 ]; do
printf "\r%s: %02d:%02d " "$label" $((seconds / 60)) $((seconds % 60))
sleep 1
seconds=$((seconds - 1))
done
echo -e "\n$label finished!"
}
for ((i = 1; i <= CYCLES; i++)); do
echo "=== Cycle $i of $CYCLES ==="
countdown $((WORK_MIN * 60)) "Work"
echo -ne "\a"
if [ "$i" -lt "$CYCLES" ]; then
countdown $((BREAK_MIN * 60)) "Break"
echo -ne "\a"
fi
done
echo "All cycles complete. Great work!"
Run a default Pomodoro session (25 min work, 5 min break, 4 cycles):
./pomodoro.sh
Or customize it:
./pomodoro.sh 50 10 3
Real-World Use Cases
- Pomodoro-style focus sessions, alternating work and break periods without needing a separate app.
- Cooking timers, running directly in a terminal window while working on something else.
- Presentation and meeting timers, giving speakers a visible countdown for time-boxed segments.
- Reminders for scheduled tasks, like “check the deployment status in 10 minutes,” without needing a full calendar app.
- CI/CD scripts that need to wait for a service to become available, combined with a countdown display for visibility during automated waits.
Automation Example: Countdown Before a Destructive Action
I use this pattern before any script that does something irreversible, like deleting files or dropping a database table, to give myself one last chance to cancel:
#!/usr/bin/env bash
echo "This will delete all files in /tmp/old_backups. Press Ctrl+C to cancel."
for i in {5..1}; do
echo -ne "Proceeding in $i...\r"
sleep 1
done
echo -e "\nProceeding with deletion."
rm -rf /tmp/old_backups/*
echo "Done."
Best Practices
- Always give the user a visible, updating display rather than a silent wait, so it’s clear the script hasn’t frozen.
- Provide sensible default durations so the script is usable without arguments.
- Support both raw seconds and human-readable durations (
5m,1h30m) for convenience. - Add both a terminal bell and, where available, a desktop notification, since a bell alone is easy to miss if you’ve stepped away.
- Keep the sleep interval at exactly 1 second for countdowns unless you have a specific reason for finer granularity, since anything more frequent wastes CPU with no real benefit to a human observer.
Optimization Tips
- For very long countdowns (hours), consider updating the display less frequently — every 10 or 30 seconds — rather than every second, to reduce unnecessary CPU wake-ups, then switch to per-second updates only in the final minute.
- Avoid spawning subprocesses like
dateinside a tight per-second loop; stick to Bash’s built-in arithmetic for decrementing the counter. - If running countdowns inside scripts triggered by cron or CI, remove or condition out any bell/notification calls, since these environments typically have no attached terminal or display to receive them.
Troubleshooting Common Issues
The terminal bell doesn’t make any sound — Many terminal emulators disable the audible bell by default and use a visual flash instead, or disable it entirely; check your terminal’s preferences/settings for “bell” behavior.
notify-send command not found — This utility is part of libnotify-bin on Debian/Ubuntu systems; install it with sudo apt install libnotify-bin, or skip that step on systems without a desktop environment.
The countdown “jumps” or skips numbers — This usually happens if something inside the loop takes noticeable time to execute; keep the loop body minimal, and rely on sleep 1 alone for timing rather than adding extra slow operations inside the loop.
Duration parsing doesn’t work as expected for inputs like “90s” — Verify your regex patterns account for the exact suffixes you’re testing; the parsing function shown above expects lowercase h, m, s suffixes specifically.
Common Mistakes to Avoid
- Assuming
sleep 1combined with a loop counter gives perfectly precise timing over long durations — minor drift can accumulate over hours. - Not handling invalid or missing input, which can cause the script to behave unexpectedly or loop forever with a negative starting value.
- Forgetting to give any feedback when the countdown finishes, leaving the user unsure whether the script is done or just paused.
- Hardcoding a single duration format (only seconds, or only minutes) instead of supporting a flexible, human-friendly input format.
Frequently Asked Questions
Can I pause and resume a Bash countdown timer? Not natively within a simple loop-based script, since Bash doesn’t have built-in pause/resume for a running loop. You’d need to trap a signal (like SIGTSTP for Ctrl+Z) and track remaining time in a variable that persists across the pause.
How accurate is a Bash-based countdown timer over long durations? Reasonably accurate for durations of a few hours, though minor drift can occur since sleep 1 doesn’t perfectly account for the small overhead of the rest of the loop body. For mission-critical timing, compare against an absolute end timestamp calculated with date +%s rather than purely decrementing a counter.
Can I run multiple countdown timers at once? Yes, run each in the background with &, though their terminal output will interleave unless you redirect each one’s output to a separate location, such as a log file or a separate terminal pane.
How do I make the countdown timer send an email or Slack message when it finishes instead of a desktop notification? Replace or supplement the notify-send call with a curl request to a webhook (for Slack) or a call to mail/sendmail (for email), triggered once the countdown loop exits.
Summary
A countdown timer is a small, satisfying script to build, and it introduces some genuinely useful Bash techniques along the way — parameter expansion with defaults, regex matching with BASH_REMATCH, formatted output with printf, and simple notification integration. From a basic countdown to a full Pomodoro-style work/break cycle, this is exactly the kind of tool that earns a permanent spot in your personal scripts folder once you’ve built it.
