All posts

August 11, 2026 6 min read

How to monitor Heroku Scheduler jobs

Heroku Scheduler is documented as 'expected but not guaranteed'. Here's what that means in practice, what Heroku's native tooling misses, and how to know immediately when a scheduled job stops running.


Heroku Scheduler's documentation contains a sentence worth reading carefully:

Scheduler job execution is expected but not guaranteed. Scheduler is known to occasionally (but rarely) miss the execution of scheduled jobs.

That sentence has real consequences for anyone running critical jobs on Heroku Scheduler. It means that a nightly backup, a daily sync, or a billing job can silently not run — with no native alert, no notification, and no indication in any Heroku dashboard unless you go looking.

Heroku's own recommendation for critical jobs is to run a custom clock process instead of Scheduler. That's the right architectural advice. But many teams are running Scheduler for jobs that matter, and replacing it with a clock process is a non-trivial change. External monitoring is the practical bridge: it tells you when a job stops running regardless of the underlying cause.


What Heroku Scheduler doesn't provide

No missed run alerts. If Scheduler skips a job execution — due to platform load, a deployment issue, or the documented but-rarely case — no notification is sent. The job simply doesn't run, and the next scheduled execution proceeds as if nothing happened.

No failure alerts by default. When a job exits non-zero, Heroku logs it. If you have email notifications configured, you may receive an alert — but the default configuration and delivery are not reliable enough for production-critical jobs.

No hung job detection. If a job starts and never exits, Heroku has no concept of a maximum duration threshold. The dyno runs, billing accumulates, and no alert fires.

No output validation. Heroku Scheduler considers a zero exit code a successful run. A job that processes no records, produces an empty export, or sends no emails is indistinguishable from a successful run.


Adding external monitoring to Heroku Scheduler jobs

The pattern works for any language Heroku supports. Your job sends HTTP pings to an external monitor at start and completion. The external monitor holds the expected schedule and fires an alert if pings don't arrive on time.

For a Node.js job:

import { CrontifyMonitor } from '@crontify/sdk';

const monitor = new CrontifyMonitor({
  apiKey: process.env.CRONTIFY_API_KEY!,
  monitorId: process.env.CRONTIFY_MONITOR_ID!,
});

async function main(): Promise<void> {
  await monitor.wrap(async () => {
    const result = await runScheduledJob();
    return {
      meta: {
        records_processed: result.count,
        errors: result.errors,
      }
    };
  });
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

For a Ruby job:

require 'net/http'
require 'json'
require 'uri'

def ping(event, payload = {})
  uri = URI("https://api.crontify.com/api/v1/ping/#{ENV['CRONTIFY_MONITOR_ID']}/#{event}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.open_timeout = 5
  http.read_timeout = 5

  request = Net::HTTP::Post.new(uri)
  request['X-API-Key'] = ENV['CRONTIFY_API_KEY']
  request['Content-Type'] = 'application/json'
  request.body = payload.to_json unless payload.empty?

  http.request(request)
rescue StandardError
  # Never let monitoring failure kill the job
end

ping('start')

begin
  result = run_job
  ping('success', { meta: { records_processed: result[:count] } })
rescue => e
  ping('fail', { message: e.message, log: e.backtrace.first(15).join("\n") })
  exit 1
end

For a Python job:

import os
import sys
import requests
import traceback

def ping(event, payload=None):
    try:
        requests.post(
            f"https://api.crontify.com/api/v1/ping/{os.environ['CRONTIFY_MONITOR_ID']}/{event}",
            headers={"X-API-Key": os.environ["CRONTIFY_API_KEY"]},
            json=payload,
            timeout=10,
        )
    except Exception:
        pass

ping("start")
try:
    result = run_job()
    ping("success", {"meta": {"records_processed": result.count}})
except Exception as exc:
    ping("fail", {"message": str(exc), "log": traceback.format_exc()})
    sys.exit(1)

Set CRONTIFY_API_KEY and CRONTIFY_MONITOR_ID as Heroku config vars:

heroku config:set CRONTIFY_API_KEY=ck_live_your_key
heroku config:set CRONTIFY_MONITOR_ID=your-monitor-id

Handling Heroku Scheduler's limited schedule options

Heroku Scheduler only supports three frequencies: every 10 minutes, every hour, or every day at a specified time. Configure your Crontify monitor with the equivalent cron expression:

Heroku Scheduler settingCrontify cron expression
Every 10 minutes*/10 * * * *
Every hour (at :00)0 * * * *
Every day at 02:00 UTC0 2 * * *

Set a grace period of at least 5 minutes for Heroku Scheduler — execution times are not guaranteed to the minute.


What external monitoring catches that Heroku's native tooling misses

The documented missed run. Heroku acknowledges Scheduler can skip executions. External monitoring detects the absent ping within minutes of the expected time.

The silent output failure. Heroku considers exit code 0 a success. Crontify's alert rules on output metadata fire when records_processed eq 0 — even though Heroku saw a clean exit.

The hung job. A start ping arrives. No success ping follows within the configured threshold. Crontify fires a hung job alert. Heroku has no equivalent mechanism.

The platform incident. In June 2025, Heroku experienced a multi-hour outage. External monitoring — running on independent infrastructure — was the only signal available during that window.


Crontify is free for up to 5 monitors — no credit card required.


Start monitoring your scheduled jobs

Free plan includes 5 monitors. No credit card required. Up and running in under 5 minutes.

Get started free →