Hung job detection: alert when a job starts but never finishes
A hung job isn't a failure, yet. The process is alive, so no error fires and no
alert sends. It's stuck on a database lock, a full-disk write that blocks forever,
or a stale NFS mount. Meanwhile, the next scheduled run can't start, and you won't
know until someone notices that the job that "always completes by 4am" is still
running at noon. The fix: send a /start ping
when the job begins and a success ping when it ends. If the success doesn't arrive
within the expected window, LastPing opens an incident while the job is still running.
Your assistant can do this for you
Monitor my long-running jobs with LastPing
#!/bin/sh — the full wrapper, handles start + exit-code + hung detection
URL="https://ping.lastping.dev/<your-monitor-id>"
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start"
/usr/local/bin/backup.sh
rc=$?
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/$rc" # 0 = success, anything else = failure
exit $rc
On this page
Why hung jobs are the hardest failure to catch
Every monitoring tool that fires on errors is blind to a hung job. The job hasn't errored: it's just stuck.
No failure signal to detect
Exception trackers, log alert rules, and on-failure pings all need the job to exit or throw. A job stuck on a blocking call never exits, never throws, and never sends anything. The monitoring stack sees a healthy job.
The next run can't start
Most schedulers prevent a new instance from starting while the previous one is running. A job stuck for 10 hours blocks every subsequent run, compounding the problem until the hung job is found and killed.
The deadline passes unnoticed
If a job is expected to finish by 04:00 and it's still running at 08:00, whatever it was supposed to produce (a backup, a report, a data sync) is four hours late, and nobody knows unless they check manually.
The start/success ping pattern
Two pings per job: one at the start, one at the end. The gap between them is your overrun detection window. Optionally a third (a step ping per stage) narrows that window to the gap between stages.
Create a monitor with an appropriate grace window
In the console, create a heartbeat monitor with your job's schedule. Set the grace window to the maximum expected runtime, not just the expected completion delay. If your backup normally takes 5 minutes but you want to alert if it exceeds 30, set grace to 30 minutes. Copy the ping URL.
https://ping.lastping.dev/<your-monitor-id>
Wrap your command with a start/exit-code reporter
The /start ping arms overrun detection. The exit-code ping maps
0 to success and anything else to failure: one pattern covers both
failure detection and hung detection.
#!/bin/sh
# /usr/local/bin/lastping-run.sh — universal wrapper
URL="https://ping.lastping.dev/<your-monitor-id>"
# Arm overrun detection
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start"
# Run the actual job
"$@"
rc=$?
# Report result: /0 = success, /1 (or any non-zero) = failure
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/$rc"
exit $rc
# crontab -e — use the wrapper for any job
0 3 * * * /usr/local/bin/lastping-run.sh /usr/local/bin/backup.sh
Combine with a timeout for resource safety
Overrun detection tells you the job is stuck. A timeout wrapper
kills it to prevent resource exhaustion. Together they cover both alerting and
automatic recovery:
#!/bin/sh
URL="https://ping.lastping.dev/<your-monitor-id>"
MAX_SECONDS=3600 # kill after 1 hour
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start"
timeout "$MAX_SECONDS" "$@"
rc=$?
# exit code 124 means timeout -> report as failure
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/$rc"
exit $rc
Python / Node.js: inline start + success pings
For in-process jobs, send the pings from code:
# Python
import urllib.request, sys
PING = "https://ping.lastping.dev/<your-monitor-id>"
def run():
urllib.request.urlopen(f"{PING}/start", timeout=10)
try:
do_work()
urllib.request.urlopen(PING, timeout=10)
except Exception:
urllib.request.urlopen(f"{PING}/fail", timeout=10)
raise
// Node.js
const PING = 'https://ping.lastping.dev/<your-monitor-id>';
async function run() {
await fetch(`${PING}/start`);
try {
await doWork();
await fetch(PING);
} catch (err) {
await fetch(`${PING}/fail`);
throw err;
}
}
Optional: report steps to catch a stall before the budget runs out
Overrun detection answers "did it finish in time?", and only when the
time is up. If the job has distinct stages, a /step ping
after each one answers "is it still moving?", and answers it in
minutes. Give the run an id, reuse it on every ping, and label each
step:
RID=$(uuidgen)
URL="https://ping.lastping.dev/<your-monitor-id>"
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/start?rid=$RID"
pg_dump mydb > /backup/db.sql
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/step?rid=$RID&step=dump"
aws s3 cp /backup/db.sql s3://backups/
curl -fsS -m 10 --retry 3 -o /dev/null "$URL/step?rid=$RID&step=upload"
curl -fsS -m 10 --retry 3 -o /dev/null "$URL?rid=$RID"
Then set step_timeout_s on the monitor to the longest gap
you expect between steps. If the gap between the /start
(or the last step) and the next step exceeds it, LastPing opens a
stalled incident that names the last step that reported, so the
alert says upload, not just late.
Three things worth knowing. Step reporting is opt-in:
a monitor with no step_timeout_s behaves exactly as it did
before, and this whole step is optional. A step never extends
the run budget: a job that reports progress forever still
overruns, which is deliberate. And step_timeout_s must be
below the run budget (max_runtime_s, or the grace window
when that is unset), because above it the run would overrun first and
the stall could never fire.
What the start/success pattern catches
Four signals, four failure modes covered, all with a single monitor.
- Job never started: no
/startping in the expected window → incident opens. Crontab lost, machine down, scheduler misconfigured, all caught. - Job started but is hung:
/startarrived; no success within grace → incident opens while the job is still running. Overrun detection fires. - Job started but stopped making progress: the run reported
/steppings and then went quiet for longer than the monitor'sstep_timeout_s→ a stalled incident opens naming the last step that reported. This is the one that tells you where it wedged, and tells you in minutes rather than at the end of the run budget. Opt-in: leavestep_timeout_sunset and the monitor behaves exactly as it always has. - Job failed:
/failor a non-zero exit code ping closes the run with failure → incident opens immediately. - Recovery: next successful run closes the incident automatically. No manual reset.
- Flap protection: a transient failure that recovers within the damping window doesn't page you repeatedly.
Running GitHub Actions? The workflow_run
webhook delivers started and completed events: no YAML changes needed. See
GitHub Actions monitoring.
For the conceptual overview of why absence is a better signal than failure, see
dead-man's switch explained and
silent failure in scheduled jobs.
For AI agents that need to report their own heartbeat, the
LastPing MCP server returns ready-to-run check-in requests for all
four signals via get_ping_instructions; the check-ins themselves are plain
HTTP.
Questions people ask
Front-loaded answers: the most important fact first.
-
How do I detect a hung job?
Send a
/startping at the beginning of the job and a success ping at the end. Set the grace window to your maximum expected runtime. If the success ping doesn't arrive within the grace window after the/start, LastPing opens an incident even though the job process is technically still running. This is called overrun detection. For a job with distinct stages you can also report a/stepping as each stage finishes and setstep_timeout_son the monitor: LastPing then opens a stalled incident naming the last step that reported, as soon as progress stops, instead of waiting for the whole run budget to expire. Step reporting is opt-in: a monitor that sets nostep_timeout_sbehaves exactly as it did before. -
What is the difference between a hung job and a failed job?
A failed job exits with a non-zero exit code, producing a signal (error log, exception) that exception monitoring can detect. A hung job is still running (its process is alive), so it produces no failure signal. The job has not failed yet; it's just not making progress. Error monitoring can catch failures; only overrun detection can catch hung jobs, because it uses time (the gap between start and expected completion) as the trigger. Without overrun detection, a hung job is invisible to all monitoring until someone notices the next scheduled run couldn't start because the previous one is still holding resources. Overrun detection tells you the job did not finish in time; stall detection (step pings plus
step_timeout_s) tells you which stage it stopped in, and tells you sooner. -
What causes jobs to hang?
Common causes: (1) database lock wait: the job is waiting for a lock held by a long-running transaction; (2) full disk: a write blocks indefinitely on some filesystems; (3) dead NFS or network mount: reads and writes block forever on a stale mount; (4) network timeout too long: the job is waiting for a response that will never come, with a default socket timeout measured in minutes or hours; (5) stdin waiting: the job is reading from stdin and blocking because it's not attached to a terminal.
-
Why can't I just set a timeout on the cron job?
You can and should:
timeout 3600 /usr/local/bin/backup.shis a good safety net that kills a stuck job automatically. But a timeout alone doesn't alert you; it just kills the job and exits with code 124. You still need external monitoring to know the job was killed and why. Combine both: a timeout for automatic cleanup and to prevent resource exhaustion, and a/start+success ping pattern so LastPing opens an incident the moment the job exceeds its expected runtime. -
How do I detect a hung GitHub Actions run?
Point a
workflow_runwebhook at LastPing. When a run starts, the webhook delivers a started event; when it completes, it delivers a completed event. If started arrives but completed doesn't arrive within the grace window, LastPing opens an incident. This catches hung runs without any YAML changes. See the GitHub Actions monitoring guide for the full setup. -
Is this free?
Yes. LastPing is free for individuals with no monitor cap. See how it compares to Healthchecks.io and Cronitor.
Know the moment a job gets stuck, not when someone notices it's late.
One wrapper script, one heartbeat monitor. Set up overrun detection in minutes, and LastPing monitors itself the same way.