Skip to content
LastPing
GUIDE · OVERRUN DETECTION · FREE

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
  1. Why hung jobs are the hardest failure to catch
  2. The start/success ping pattern
  3. What the start/success pattern catches
  4. Questions people ask

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.

Problem 01

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.

Problem 02

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.

Problem 03

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 /start ping in the expected window → incident opens. Crontab lost, machine down, scheduler misconfigured, all caught.
  • Job started but is hung: /start arrived; no success within grace → incident opens while the job is still running. Overrun detection fires.
  • Job started but stopped making progress: the run reported /step pings and then went quiet for longer than the monitor's step_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: leave step_timeout_s unset and the monitor behaves exactly as it always has.
  • Job failed: /fail or 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 /start ping 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 /step ping as each stage finishes and set step_timeout_s on 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 no step_timeout_s behaves 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.sh is 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_run webhook 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.

FREE FOR INDIVIDUALS · FULLY HOSTED

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.