Cron Job Timeout: How to Kill Hanging Jobs Before They Stack

A cron job timeout isn't something you think about until a job that normally finishes in 30 seconds hangs for six hours. By then it has spawned five more copies of itself (cron doesn't check if the previous run finished), your database has six connections doing the same work, and the server is at 94% memory. I've seen this take down a production MySQL instance on a Saturday morning.

Cron has no built-in timeout. It starts your command and forgets about it. If the job hangs on a DNS lookup, a locked row, or a remote API that stopped responding, cron won't kill it. You have to handle that yourself.

The timeout command: simplest cron job timeout

GNU coreutils includes timeout, available on every modern Linux distro. It runs a command and kills it after a specified duration:

# Kill the backup script if it runs longer than 5 minutes
0 3 * * * timeout 300 /usr/bin/php /var/www/app/backup.php >> /var/log/backup.log 2>&1

# Same thing, human-readable
0 3 * * * timeout 5m /usr/bin/php /var/www/app/backup.php >> /var/log/backup.log 2>&1

Duration accepts seconds (plain number), minutes (5m), hours (2h), or days (1d). Pick a value 2-3x the normal runtime. If the job typically finishes in 45 seconds, set timeout 2m. Too tight and you'll kill healthy runs that hit a slow query.

SIGTERM vs SIGKILL: the two-step kill

By default, timeout sends SIGTERM (signal 15). Most programs handle SIGTERM gracefully: they close database connections, flush buffers, release locks. But some programs ignore SIGTERM. A hung PHP script waiting on a socket read won't respond to it.

The --kill-after flag adds a SIGKILL (signal 9) as a fallback:

# Send SIGTERM after 5 minutes, SIGKILL 30 seconds later if still alive
0 3 * * * timeout --kill-after=30s 5m /usr/bin/php /var/www/app/backup.php >> /var/log/backup.log 2>&1

SIGKILL can't be caught or ignored. The process dies. No cleanup, no graceful shutdown, no "one more second." Use it as the last resort, not the default. A process killed by SIGKILL may leave lock files behind, corrupt a half-written file, or hold a database transaction open until the connection times out on its own.

Exit codes from timeout

When timeout kills a process, it returns exit code 124. If the process exits on its own, you get its original exit code. This matters for logging and monitoring:

timeout 5m /var/www/app/backup.sh
echo $?
# 0   = finished normally
# 1   = script error
# 124 = killed by timeout (SIGTERM)
# 137 = killed by SIGKILL (128 + 9)

If you're using a logging wrapper, check for exit code 124 specifically. It means the job didn't fail — it hung, which is a different problem with different causes.

Combining timeout with flock

Timeout and overlap prevention solve different problems but work best together. Without both, you get either stacking (no flock) or immortal jobs (no timeout):

# Prevent overlap AND enforce a 10-minute timeout
*/5 * * * * /usr/bin/flock -n /tmp/import.lock timeout 10m /usr/bin/php /var/www/app/import.php >> /var/log/import.log 2>&1

Order matters. flock goes first — it decides whether to run at all. timeout goes second — it wraps the actual command. If you reverse them, timeout applies to flock's wait time, not the job's runtime.

For a job that runs every minute, this combination is close to mandatory. The five types of cron failure article covers the pileup scenario in detail. Without both guards, one slow run causes a pileup that can exhaust your server's resources before anyone notices.

Language-specific timeouts

PHP

PHP has max_execution_time, but it only counts CPU time in CLI mode, not wall clock time. A script sleeping on a socket read or waiting for a MySQL response doesn't count against it. For cron jobs, it's useless.

set_time_limit() gives you wall-clock enforcement, but honestly the timeout command in crontab is simpler and works regardless of language. If you must handle it in PHP:

// Set an alarm signal
pcntl_alarm(300); // 5 minutes
pcntl_signal(SIGALRM, function () {
    fwrite(STDERR, "Timeout: job exceeded 300 seconds\n");
    exit(124); // Match timeout command's exit code
});

This requires the pcntl extension, which is available in CLI but not in php-fpm. Check with php -m | grep pcntl. On most distros it's included by default for the CLI SAPI — we covered the CLI vs web SAPI split in the PHP cron guide.

Python

Python's signal.alarm() works the same way:

import signal
import sys

def timeout_handler(signum, frame):
    print("Timeout: job exceeded limit", file=sys.stderr)
    sys.exit(124)

signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(300)  # 5 minutes

# Your work here
run_import()

signal.alarm(0)  # Cancel the alarm on success

For Python cron jobs that make HTTP requests, also set timeouts on individual operations. A 5-minute job timeout won't help if a single requests.get() blocks forever:

import requests

# Always set connect and read timeouts
response = requests.get("https://api.example.com/data", timeout=(5, 30))

Node.js

Node doesn't have SIGALRM. Use setTimeout with process.exit():

// Force exit after 5 minutes
const TIMEOUT_MS = 5 * 60 * 1000;
const timer = setTimeout(() => {
    console.error("Timeout: job exceeded 5 minutes");
    process.exit(124);
}, TIMEOUT_MS);
timer.unref(); // Don't keep the process alive just for this timer

// Your work here
await runImport();
clearTimeout(timer);

The .unref() is important. Without it, the timer itself keeps the Node process running even after your work finishes. More Node cron patterns in the dedicated guide.

Detecting hung jobs with monitoring

Timeouts kill jobs that run too long. But you also need to know it happened. A job killed by timeout at 3 AM doesn't announce itself. You find out Monday morning when someone asks why the reports are empty.

Heartbeat monitoring catches this naturally. If your job pings only on success, a timeout means no ping, which means an alert:

# The ping only fires if timeout doesn't kill the job AND the script exits 0
0 3 * * * timeout 5m /var/www/app/backup.sh && curl -fsS --max-time 10 https://watchcron.com/ping/abc123 > /dev/null

The && is key. If timeout kills the process (exit 124), curl never runs, the dead man's switch doesn't get its ping, and alerts fire. No extra code needed.

For more granularity, send a failure signal when the job times out:

#!/bin/bash
timeout 5m /var/www/app/backup.sh
EXIT=$?

if [ $EXIT -eq 0 ]; then
    curl -fsS https://watchcron.com/ping/abc123 > /dev/null
elif [ $EXIT -eq 124 ]; then
    curl -fsS https://watchcron.com/ping/abc123/fail > /dev/null
    echo "TIMEOUT: backup exceeded 5 minutes" >&2
fi

exit $EXIT

What causes cron jobs to hang

Before setting a timeout, worth understanding why a cron job hangs. I've debugged all of these at least once. The fix for each is different.

DNS resolution. A script that connects to api.example.com hangs if the DNS server is slow or unreachable. Most system resolvers have a 30-second timeout with 2 retries — that's 90 seconds of nothing before the connection even fails. Set RES_OPTIONS="timeout:5 attempts:1" in crontab's environment if this bites you. The cron cheat sheet covers environment variables in crontab.

Database locks. An import script tries to UPDATE a row that another transaction holds. InnoDB waits innodb_lock_wait_timeout seconds (default: 50). If the locking transaction is another copy of the same job that shouldn't be running (overlap), you get a deadlock that resolves itself eventually, but wastes the entire wait time.

Remote API calls. An HTTP request to a third-party service that stops responding. The OS-level TCP timeout is usually 2+ minutes. Without a connect/read timeout in your HTTP client, a single stalled API call can block the entire job. If the API itself is down, uptime monitoring catches it separately.

File locks. flock() with LOCK_EX (without LOCK_NB) waits forever for the lock. If the process holding the lock died without releasing it (killed by SIGKILL, OOM killer), the lock persists until the file descriptor closes. On some filesystems, that's until the next reboot.

Disk I/O. NFS mounts that go stale, RAID rebuilds that make writes take 10x longer, or a nearly full disk where ext4 starts searching for free blocks. Docker containers add another layer: volume mounts to slow storage can make I/O unpredictable. The script isn't stuck — it's waiting for I/O that used to be fast.

Timeout values: how to pick the right number

Measure first. Run the job 20 times, record the duration each time. Take the 95th percentile (the second-slowest run) and multiply by 2. That's your timeout.

# Quick way to measure: add this to the cronlog wrapper
START=$(date +%s)
/var/www/app/backup.sh
echo "Duration: $(($(date +%s) - START))s" >> /var/log/backup-timing.log

A job that usually takes 12 seconds but occasionally takes 45 on busy nights should get a timeout 90s or timeout 2m. Setting timeout 15s will kill the slow-but-healthy runs. Setting timeout 1h defeats the purpose.

Revisit the number every few months. Data grows, APIs get slower, and a cron job timeout that was generous in January starts killing healthy runs by June.

---

Frequently asked

No. Cron starts your command and does not track how long it runs. There is no timeout directive in crontab syntax. You have to wrap your command with the timeout utility or handle it inside your script.

Exit code 124 when it kills the process via SIGTERM. If --kill-after triggers a SIGKILL, the exit code is 137 (128 + signal 9). If the command finishes before the timeout, you get the command's own exit code.

Use flock -n /tmp/job.lock before your command. The -n flag makes it exit immediately if the lock is held. Combine with timeout to handle both overlap and hangs: flock -n /tmp/job.lock timeout 5m /path/to/script.sh.

Not reliably. In CLI mode, max_execution_time counts only CPU time, not wall-clock time. A script waiting on a database query or HTTP response doesn't count. Use the timeout command in crontab or pcntl_alarm() inside the script for wall-clock enforcement.

Check the exit code: 124 means SIGTERM from timeout, 137 means SIGKILL. If you use heartbeat monitoring, a timed-out job won't send its success ping, triggering an alert automatically.