Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

bash wait: Coordinating Background Jobs Without a Sleep and a Prayer

Sean

Platform Writer

Aug 13, 2026
8 min read

wait with no arguments blocks until every background job started by the current shell has finished. wait $PID waits for one and returns its exit status, which is the part that makes it useful rather than merely blocking. Any script that starts work with & and then calls sleep 10 hoping it is done should be using wait instead.

bash wait: Coordinating Background Jobs Without a Sleep and a Prayer

The command is a shell builtin with three or four genuinely different behaviours depending on what you pass it, and the exit-status rules are where scripts quietly go wrong.

Table of contents

The basic forms

# Wait for everything
sleep 3 &
sleep 5 &
wait
echo "both finished"

# Wait for one specific job and capture its status
long_task &
pid=$!
wait "$pid"
echo "long_task exited with $?"

# Wait for several named jobs
task_a & a=$!
task_b & b=$!
wait "$a" "$b"

$! holds the PID of the most recently backgrounded job. Capture it immediately — start another background job and it is overwritten.

The exit-status rule is the important detail. Bare wait returns 0 regardless of whether the jobs succeeded. wait $pid returns that job’s actual exit code. If you care whether the work succeeded — and you should — you must wait on PIDs individually or use -n.

Running work in parallel and collecting failures

This is the pattern worth copying. Start jobs, keep their PIDs, wait on each, and record which failed.

#!/bin/bash
set -uo pipefail

declare -A pids

for svc in api worker scheduler; do
    ./build.sh "$svc" > "logs/$svc.log" 2>&1 &
    pids[$svc]=$!
done

failed=()
for svc in "${!pids[@]}"; do
    if ! wait "${pids[$svc]}"; then
        failed+=("$svc")
    fi
done

if (( ${#failed[@]} )); then
    echo "failed: ${failed[*]}" >&2
    exit 1
fi
echo "all builds succeeded"

Note set -uo pipefail without -e. With errexit on, the first failing wait would end the script and you would never learn that two other builds also failed. Collecting the failures and reporting them together is more useful than stopping at the first.

Redirecting each job’s output to its own file matters too. Parallel jobs writing to the same terminal interleave mid-line and produce output nobody can read.

wait -n: react as each job finishes

wait -n returns as soon as any one background job completes, rather than waiting for all of them. It is how you build a worker pool that keeps N jobs running.

#!/bin/bash
max_parallel=4

for url in $(cat urls.txt); do
    # Throttle: if at capacity, wait for a slot
    while (( $(jobs -rp | wc -l) >= max_parallel )); do
        wait -n
    done
    fetch "$url" &
done
wait

wait -n returns the exit status of whichever job finished. In Bash 5.1 and later, wait -n -p var also stores that job’s PID in var, so you can tell which one it was:

wait -n -p finished_pid
echo "pid $finished_pid exited with $?"

Before 5.1 you cannot identify the job from wait -n alone, which is worth knowing if the script has to run on an older macOS shipping Bash 3.2.

The timeout problem

wait has no timeout, and this is its main limitation. The usual answers are a watchdog or timeout on the job itself.

# Cleanest: put the timeout on the command, not the wait
timeout 30 long_task &
pid=$!
wait "$pid"
status=$?
if (( status == 124 )); then
    echo "timed out"
fi

timeout exits 124 when it fires, which gives you a clean way to distinguish a timeout from an ordinary failure.

If you cannot wrap the command, a watchdog works:

long_task & pid=$!
( sleep 30; kill -TERM "$pid" 2>/dev/null ) & watchdog=$!
wait "$pid"; status=$?
kill -TERM "$watchdog" 2>/dev/null   # cancel the watchdog
wait "$watchdog" 2>/dev/null || true

Slightly fiddly, and a good sign that the logic has outgrown a shell script.

Waiting for a condition rather than a process

“Wait until the database is accepting connections” is a different problem — there is no PID to wait on. Poll with a bounded loop, never a bare sleep.

wait_for_port() {
    local host=$1 port=$2 timeout=${3:-30}
    local start=$SECONDS
    until nc -z "$host" "$port" 2>/dev/null; do
        if (( SECONDS - start >= timeout )); then
            echo "timed out waiting for $host:$port" >&2
            return 1
        fi
        sleep 0.5
    done
}

wait_for_port localhost 5432 60 || exit 1
run_migrations

The bounded loop is the point. sleep 30 before running migrations is a guess that is simultaneously too long on a fast machine and too short on a slow one — the worst of both.

Signals, traps, and cleanup

A script that starts background jobs should clean them up if it is interrupted, or you leave orphans behind.

#!/bin/bash
pids=()

cleanup() {
    echo "stopping background jobs" >&2
    kill "${pids[@]}" 2>/dev/null
    wait "${pids[@]}" 2>/dev/null
}
trap cleanup EXIT INT TERM

worker_a & pids+=($!)
worker_b & pids+=($!)
wait

One subtlety: while bash is blocked in wait, a trapped signal is not handled until the current foreground command finishes. In practice wait is interruptible, but if a trap seems to be ignored during a long wait, this is the mechanism behind it.

When the shell script has outgrown itself

Parallel jobs, exit-status collection, timeouts, watchdogs, and signal cleanup — that is a scheduler, and you have written it in bash.

It is a reasonable place to be for build steps and one-off tasks. It stops being reasonable when the script is the thing running in production: there is no retry policy you did not write, no history of previous runs, and no way to see what happened except whatever you remembered to redirect to a file.

Background work that needs to survive, be observed, and be retried belongs in a service with logs rather than a script with &. On RunxBuild that means a worker service with its own runtime logs and deploy history, which is a smaller step than it sounds when the script already exists — and removes the part where the only record of last Tuesday’s failure was a terminal someone has since closed.

How this fits the rest of the stack

wait for everything, wait $pid for one job and its real exit code, wait -n to react as each finishes. Bare wait always returns 0, so collect PIDs if you care about failures. Use timeout on the command rather than trying to time out the wait, and trap EXIT INT TERM to clean up jobs you started.

When the script grows a scheduler, that is the signal to move the work into something with logs and history. If you are costing that out, the RunxBuild hosting calculator puts the service, database, storage, and bandwidth on one page as separate numbers.

Useful related references:

FAQ

What does the bash wait command do?

It blocks the shell until background jobs finish. With no arguments it waits for all of them and returns 0; with a PID it waits for that job and returns that job’s exit status. It is the correct alternative to guessing a duration with sleep.

How do I get the exit code of a background job in bash?

Capture the PID with $! right after starting the job, then wait "$pid" and read $?. Bare wait discards individual statuses and always returns 0, so waiting on specific PIDs is the only way to detect a failure.

What is the difference between wait and wait -n?

wait blocks until all background jobs finish. wait -n returns as soon as any single job finishes, along with that job’s exit status. -n is what you use to keep a fixed number of parallel jobs running, waiting for a slot before starting the next.

Does bash wait support a timeout?

No. Wrap the job in timeout 30 command & instead and check for exit status 124, which is what timeout returns when it fires. The alternative is a watchdog subshell that kills the job after a delay, but wrapping the command is cleaner.

How do I wait for a service to be ready rather than a process to exit?

There is no PID to wait on, so poll with a bounded loop: repeatedly test the port or a health endpoint with a small sleep between attempts, and give up after a deadline. A fixed sleep before continuing is a guess that is too long on fast machines and too short on slow ones.

#bash wait#bash scripting#background jobs#parallel#shell