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.
---