Cron job monitoring, the simple way

A cron job that fails loudly is easy — you see the error in your logs. A cron job that just stops running is much worse: no error, no crash, nothing in your dashboard. It just quietly stops happening.

Common ways scheduled jobs go quiet

  • A Vercel Cron job is removed or misconfigured after a deploy, and nobody notices because there's no failure to see.
  • A GitHub Actions scheduled workflow gets auto-disabled after 60 days of repo inactivity (GitHub does this silently).
  • A Supabase Edge Function cron trigger is deleted during a migration.
  • The job runs but exits early due to a bug, without raising an exception.

The pattern: a heartbeat check

This is sometimes called a "dead man's switch". Instead of monitoring whether your job exists, you monitor whether it recently ran. The job pings a unique URL after it finishes; if the ping doesn't arrive within your expected interval plus a grace period, you get alerted.

Vercel Cron (vercel.json)
{
  "crons": [
    { "path": "/api/nightly-job", "schedule": "0 3 * * *" }
  ]
}

// at the end of /api/nightly-job, after success:
await fetch("https://silentwatch.app/api/ping/sw_your_token");
GitHub Actions
name: nightly-job
on:
  schedule:
    - cron: "0 3 * * *"
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - run: ./run-nightly-job.sh
      - run: curl -fsS "https://silentwatch.app/api/ping/sw_your_token"
Supabase Edge Function
Deno.serve(async () => {
  // ... your job ...
  await fetch("https://silentwatch.app/api/ping/sw_your_token");
  return new Response("ok");
});

Setting the right grace period

Set the grace period a little longer than your job's normal run-time variance. A daily job that usually takes 2 minutes but occasionally takes 20 should have at least a 30+ minute grace period, so a slow run doesn't trigger a false alert.