How to Create a Bash Stopwatch

How to Create a Bash Stopwatch

I built my first Bash stopwatch out of pure necessity while timing how long a batch of database migrations took to run overnight. I didn’t want to install anything extra, and a stopwatch felt like exactly the kind of small, satisfying tool that Bash is perfectly suited for. In this article I’ll walk you through building one from the ground up, starting with the simplest possible version and working up to a stopwatch with lap times and a clean, readable display.

The Core Idea Behind a Bash Stopwatch

A stopwatch fundamentally just needs to do two things: record a starting timestamp, and continuously calculate the difference between “now” and that starting point. Bash gives us everything we need for this through the built-in $SECONDS variable and the date command.

$SECONDS is a special Bash variable that automatically tracks the number of seconds since the shell (or script) started running. It resets to zero whenever you assign it a new value, which makes it perfect for this purpose.

Step 1: The Simplest Possible Stopwatch

Let’s start with a minimal version. Save this as stopwatch.sh:

#!/usr/bin/env bash

SECONDS=0

echo "Stopwatch started. Press Ctrl+C to stop."

trap 'echo; echo "Elapsed time: ${SECONDS} seconds"; exit 0' SIGINT

while true; do
    echo -ne "Elapsed: ${SECONDS}s\r"
    sleep 1
done

Make it executable and run it:

chmod +x stopwatch.sh
./stopwatch.sh

You’ll see the elapsed time updating every second in place, and pressing Ctrl+C will stop it and print the final elapsed time.

Explaining How This Works

  • SECONDS=0 resets Bash’s internal timer to zero the moment the script starts.
  • echo -ne "Elapsed: ${SECONDS}s\r" prints the elapsed time without a trailing newline (-n), interprets escape sequences (-e), and uses \r (carriage return) to move the cursor back to the start of the line, so each update overwrites the previous one instead of printing a new line every second.
  • trap 'commands' SIGINT catches Ctrl+C and runs a cleanup block that prints the final time and exits with status 0, rather than the script just dying abruptly mid-line.
  • sleep 1 pauses for one second between updates, matching the resolution we’re displaying at.

Step 2: Formatting Time as HH:MM:SS

Displaying raw seconds gets hard to read once you pass a minute or two. Let’s format it properly:

#!/usr/bin/env bash

SECONDS=0

format_time() {
    local total=$1
    local hours=$((total / 3600))
    local minutes=$(((total % 3600) / 60))
    local secs=$((total % 60))
    printf "%02d:%02d:%02d" "$hours" "$minutes" "$secs"
}

trap 'echo; echo "Final time: $(format_time $SECONDS)"; exit 0' SIGINT

echo "Stopwatch started. Press Ctrl+C to stop."

while true; do
    echo -ne "Elapsed: $(format_time $SECONDS)\r"
    sleep 1
done

The format_time function does the arithmetic to convert total seconds into hours, minutes, and seconds using integer division (/) and modulo (%), then printf "%02d:%02d:%02d" pads each value to two digits with leading zeros, giving you a clean 00:03:27 style display.

Step 3: Adding Lap Times

A real stopwatch usually supports “laps” — recording a split time without stopping the overall timer. Here’s a version that lets you press Enter to record a lap:

#!/usr/bin/env bash

SECONDS=0
LAP=1

format_time() {
    local total=$1
    printf "%02d:%02d:%02d" $((total / 3600)) $(((total % 3600) / 60)) $((total % 60))
}

echo "Stopwatch started."
echo "Press ENTER to record a lap, or Ctrl+C to stop."

trap 'echo; echo "Total time: $(format_time $SECONDS)"; exit 0' SIGINT

while true; do
    read -t 1 -s && {
        echo "Lap $LAP: $(format_time $SECONDS)"
        LAP=$((LAP + 1))
    }
done

Explaining the Lap Logic

The trick here is read -t 1 -s. Normally read waits indefinitely for input, but -t 1 gives it a one-second timeout, and -s keeps it silent (no echoing of typed characters). This means the loop checks once per second whether Enter was pressed; if it was, read returns success (exit code 0), and the && runs the block that prints the lap. If a second passes with no input, read times out, returns a non-zero exit code, and the loop just continues — effectively giving us a non-blocking, one-second polling stopwatch.

Step 4: A Countdown-Style Progress Bar Variant

Sometimes I want a visual bar rather than just numbers, especially when timing something with a rough expected duration. Here’s a stopwatch with a simple animated progress indicator:

#!/usr/bin/env bash

SECONDS=0
SPINNER='/-\|'

trap 'echo; echo "Final time: ${SECONDS}s"; exit 0' SIGINT

i=0
while true; do
    i=$(( (i + 1) % 4 ))
    printf "\r[%c] Elapsed: %ds " "${SPINNER:$i:1}" "$SECONDS"
    sleep 0.25
done

This cycles through the characters / - \ | to create a simple spinning animation alongside the elapsed seconds, updating four times per second for a smoother feel than a plain number ticking up.

Real-World Use Cases

  • Timing long-running scripts or migrations, so you have a rough sense of duration without setting up dedicated monitoring.
  • Practicing timed exercises, like coding katas, workouts, or public speaking rehearsals, directly from the terminal.
  • Benchmarking manual processes where you want a lightweight timer running alongside other terminal work.
  • Classroom or presentation timing, where a simple visible stopwatch on a projected terminal is enough.

Automation Example: Wrapping Another Command with Timing

A practical variant is timing how long an arbitrary command takes, then reporting it in a friendly format:

#!/usr/bin/env bash

format_time() {
    local total=$1
    printf "%02d:%02d:%02d" $((total / 3600)) $(((total % 3600) / 60)) $((total % 60))
}

SECONDS=0
"$@"
STATUS=$?

echo "Command finished in $(format_time $SECONDS) with exit code $STATUS"

Usage:

./timed_run.sh ./backup_script.sh

This is genuinely one of my most-used personal scripts — I wrap any long command with it when I’m curious how long something takes, without needing to install time alternatives or parse time‘s sometimes awkward output format.

Best Practices

  • Use $SECONDS instead of repeatedly calling date +%s in a loop — it’s simpler and avoids unnecessary subprocess calls.
  • Always trap SIGINT so stopping the stopwatch feels intentional and produces a clean final readout, rather than leaving a half-printed line in the terminal.
  • Use printf for formatted output instead of echo, since printf handles padding and formatting far more reliably across different shells and systems.
  • Keep the update interval reasonable — updating faster than a few times per second wastes CPU cycles for no visible benefit in a terminal display.

Optimization Tips

  • If you don’t need sub-second precision, sleep 1 is efficient and keeps CPU usage effectively at zero between updates.
  • Avoid spawning external processes like date inside a tight loop if you can use Bash’s built-in $SECONDS instead — it avoids the overhead of forking a new process every iteration.
  • If you want millisecond precision, you’ll need date +%s%N (nanoseconds) instead of $SECONDS, since Bash’s built-in timer only has one-second resolution.

Troubleshooting Common Issues

The elapsed time display doesn’t update correctly, or lines stack up instead of overwriting — Make sure you’re using echo -ne (or printf without a newline) along with \r, and that your terminal supports carriage return behavior properly; some non-interactive contexts like log files won’t render \r visually.

Ctrl+C doesn’t stop the script cleanly — Confirm your trap is registered before the loop starts, and double-check you haven’t accidentally overridden it later in the script.

Time appears to drift over long runs — sleep 1 doesn’t account for the time the rest of the loop body takes to execute, so over very long durations small drift can accumulate. For precision-critical timing, calculate elapsed time from an actual start timestamp (date +%s) rather than accumulating sleep intervals.

Common Mistakes to Avoid

  • Relying purely on accumulated sleep calls for precise timing instead of comparing against a fixed start timestamp, which causes drift over long sessions.
  • Forgetting to trap SIGINT, leaving the terminal in an awkward state after Ctrl+C.
  • Using echo without -n or -e, resulting in a stopwatch that scrolls a new line every second instead of updating in place.
  • Not testing the lap functionality’s edge cases, like pressing Enter multiple times in rapid succession.

Frequently Asked Questions

Can I get millisecond precision in a Bash stopwatch? Yes, but you’ll need date +%s%N for nanosecond-resolution timestamps and calculate differences yourself, since $SECONDS only has one-second granularity.

Why does my stopwatch drift by a few seconds over a long run? Because sleep 1 doesn’t account for the small amount of time your loop body itself takes to execute. For long-running or precision-sensitive stopwatches, base your elapsed time calculation on the difference between the current timestamp and a fixed start timestamp rather than accumulating sleep calls.

Can I run this stopwatch in the background while doing other terminal work? Yes, run it with & to background it, though you’ll lose the live in-place display since it competes with your terminal’s other output. A better approach for background timing is to just record start and end timestamps and calculate the difference afterward.

How do I add a pause/resume feature? You’d need to track accumulated elapsed time separately from the current run segment, pausing by recording the current elapsed time and stopping the loop, then resuming by resetting $SECONDS to 0 and adding the previously accumulated time when displaying.

Summary

A Bash stopwatch is a small project, but it touches on several genuinely useful Bash concepts: the $SECONDS built-in, trap for graceful interruption, printf for clean formatted output, and non-blocking input handling with read -t. Starting from the basic version, you can extend it with lap times, spinner animations, or wrap it around other commands to time your everyday scripts. It’s one of those small tools that, once built, ends up getting used far more often than you’d expect.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Create a Bash Countdown Timer

How to Create a Bash Countdown Timer

Next Post
How to Create a Bash File Transfer Utility

How to Create a Bash File Transfer Utility

Related Posts