Heroku Scheduler is deceptively simple to set up. Add a command, pick a frequency, done. But there's a critical gap: if your job fails, throws an exception, or simply stops running, Heroku Scheduler says nothing. No alert, no email, no dashboard warning. You find out from a user complaint or a missing report.
This is the same problem shared by every fire-and-forget scheduler. The solution is the same too: the dead man's switch pattern.
Heroku Scheduler runs your command on a dyno, captures its output, and moves on. There are three failure scenarios it does not alert on:
Heroku does provide log drains and you can pipe logs to Papertrail or Datadog. But that still requires you to write a log parser rule for every job — and it won't catch the case where a job simply doesn't fire.
A dead man's switch works in reverse. Instead of your monitoring tool watching for an error, your job sends a "I'm alive" ping to a monitoring service at the end of every successful run. If the ping stops arriving, the monitoring service alerts you.
This catches every failure mode:
The implementation is one line added to the end of your job. If the ping line is reached, the job succeeded.
# lib/tasks/daily_report.rake
task :daily_report do
# ... your job logic here ...
generate_report
send_emails
# Heartbeat ping — fires only on successful completion
require 'net/http'
Net::HTTP.get(URI('https://cronwatch.ping/YOUR_TOKEN'))
end
Or with the Faraday or HTTParty gem if you already have one in your Gemfile:
HTTParty.get('https://cronwatch.ping/YOUR_TOKEN') rescue nil
The rescue nil ensures a network hiccup doesn't fail your job — the ping is best-effort.
import requests
def daily_report():
# ... your job logic here ...
generate_report()
send_emails()
# Heartbeat ping — fires only on successful completion
try:
requests.get('https://cronwatch.ping/YOUR_TOKEN', timeout=10)
except Exception:
pass # Don't let a network issue fail the job
if __name__ == '__main__':
daily_report()
const https = require('https');
async function dailyReport() {
// ... your job logic here ...
await generateReport();
await sendEmails();
// Heartbeat ping — fires only on successful completion
https.get('https://cronwatch.ping/YOUR_TOKEN').on('error', () => {});
}
dailyReport();
For Heroku Scheduler tasks that run a shell command directly:
ruby lib/tasks/daily_report.rb && curl -s https://cronwatch.ping/YOUR_TOKEN
The && ensures the curl only runs if the previous command exited 0. If the Ruby script fails, no ping is sent.
Heroku Scheduler has timing jitter. A job scheduled to run every hour may run at :00, :02, :01, depending on dyno availability. Your monitoring tool needs to account for this with a configurable grace period — typically 10–20 minutes for hourly jobs, and 2–3 hours for daily ones.
Without a grace period, you'll get false alerts every time Heroku is a few minutes slow. With too long a grace period, you'll miss real failures.
| Job frequency | Recommended grace period |
|---|---|
| Every 10 minutes | 5 minutes |
| Every hour | 15 minutes |
| Every day | 2 hours |
Not all monitoring tools are built for Heroku's fire-and-forget model. Here's what matters:
Once you have monitoring in place, you'll start seeing which jobs actually fail. Common causes for Heroku Scheduler jobs:
CronWatch monitors your Heroku Scheduler jobs with the dead man's switch pattern. Add one line to your job. If it stops firing, you get an alert. Configurable grace periods, email alerts, simple setup.
Join the waitlist → Parse cron expressions free