<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>CronRadar Blog</title>
    <link>https://cronradar.com/blog</link>
    <description>Guides, tutorials, and best practices for monitoring cron jobs and scheduled tasks</description>
    <language>en</language>
    <lastBuildDate>Sat, 31 Oct 2026 16:05:15 GMT</lastBuildDate>
    <atom:link href="https://cronradar.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
    
    <item>
      <title><![CDATA[APScheduler Running Twice: Gunicorn Workers, Reloaders, and --preload]]></title>
      <link>https://cronradar.com/blog/apscheduler-duplicate-runs</link>
      <guid>https://cronradar.com/blog/apscheduler-duplicate-runs</guid>
      <description><![CDATA[Why APScheduler 3.x runs jobs once per Gunicorn or Uvicorn worker and twice under Flask and Django reloaders, what --preload does, and what each fix costs.]]></description>
      <content:encoded><![CDATA[<p><strong>APScheduler runs each job once per scheduler, and a <code>BackgroundScheduler</code> started when your app loads creates one scheduler in every process that loads it: each Gunicorn or Uvicorn worker, both processes of Flask's debug reloader, and both processes of Django's <code>runserver</code> autoreloader.</strong> APScheduler 3.x has no coordination between processes, so every one of them fires the job. The fix is to run the schedule in exactly one process, either a dedicated one or the single web worker that wins a lock, and to guard the development reloaders separately.</p>
<p>This guide covers APScheduler 3.11.3, the current release on <a href="https://pypi.org/project/APScheduler/3.11.3/">PyPI</a> (checked October 30, 2026); the 4.0 line is still pre-release there, at 4.0.0a6. Server behavior is checked against the source of Gunicorn 26.2.0, Uvicorn 0.54.0, Flask 3.1.3 with Werkzeug 3.1.9, and Django 6.1.2. For APScheduler's general failure modes and events, see <a href="https://cronradar.com/blog/python-scheduler-monitoring">Python scheduler monitoring</a>; for duplicates caused by crontab, systemd, or Kubernetes, see <a href="https://cronradar.com/blog/cron-job-running-twice">why a cron job runs twice</a>.</p>
<h2>Count the Schedulers First</h2>
<p>Log the process every time a scheduler starts. <code>start()</code> dispatches <code>EVENT_SCHEDULER_STARTED</code> once it is running (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L215">base.py</a>), so add the listener before calling it:</p>
<pre><code class="language-python">import logging
import os

from apscheduler.events import EVENT_SCHEDULER_STARTED

log = logging.getLogger("scheduler")


def log_scheduler_start(event):
    log.warning("APScheduler started: pid=%s parent=%s", os.getpid(), os.getppid())


scheduler.add_listener(log_scheduler_start, EVENT_SCHEDULER_STARTED)
scheduler.start()
</code></pre>
<p>Then compare the duplicate runs:</p>
<table>
<thead>
<tr>
<th>What the log shows</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>Several start lines with different PIDs, one per web worker</td>
<td>One scheduler per worker process</td>
</tr>
<tr>
<td>Two start lines in development, one PID the parent of the other</td>
<td>The Flask or Django reloader</td>
</tr>
<tr>
<td>One start line, but the job runs back to back right after a restart</td>
<td>Catch-up of missed run times</td>
</tr>
<tr>
<td>One start line, and the job runs more times per tick after each deploy</td>
<td>Extra copies of the job in a persistent job store</td>
</tr>
</tbody>
</table>
<p>The last two have one scheduler and are covered under <a href="#when-one-scheduler-runs-the-job-twice">when one scheduler runs the job twice</a>.</p>
<h2>Why Every Web Worker Runs Its Own Scheduler</h2>
<p><code>BackgroundScheduler.start()</code> starts a thread named <code>APScheduler</code> in the calling process and returns (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/background.py#L29-L37">background.py</a>). Each process that runs that code gets its own scheduler object, its own job store, and its own executor. Nothing connects them. The <a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L74-L91">APScheduler 3.x FAQ</a> is direct about the shared-database version of this:</p>
<blockquote>
<p>Sharing a persistent job store among two or more processes will lead to incorrect scheduler behavior like duplicate execution or the scheduler missing jobs, etc. This is because APScheduler does not currently have any interprocess synchronization and signalling scheme...</p>
</blockquote>
<p>The <a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L47">user guide</a> adds: "Job stores must never be shared between schedulers." With the default in-memory store, every worker holds a full private copy of every job, so each one runs all of them. Moving to <code>SQLAlchemyJobStore</code> doesn't fix this. It replaces independent copies with schedulers racing over the same rows.</p>
<p>The FAQ entry on web applications says to "First read through the previous section" (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L93-L96">faq.rst</a>), which is the job store entry above. How many processes you get depends on the server.</p>
<h3>Gunicorn</h3>
<p>Without <code>--preload</code>, each worker loads your WSGI app after it has been forked (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/workers/base.py#L147-L149">workers/base.py</a>), so code at import time, or in an app factory, runs once per worker. <code>-w 4</code> means four schedulers. The worker count defaults to the <code>WEB_CONCURRENCY</code> environment variable when it is set (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L717-L736">config.py</a>), so a platform that sets it can turn one scheduler into several without a change to your command.</p>
<p>Worker restarts start schedulers too. With <code>--max-requests</code>, a worker restarts after that many requests (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L813-L830">config.py</a>), and its replacement loads the app again, starting a new scheduler as the old one exits with its worker.</p>
<h3>Uvicorn</h3>
<p><code>uvicorn app:app --workers 4</code> starts four worker processes from a <code>spawn</code> multiprocessing context (<a href="https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/_subprocess.py#L18">_subprocess.py</a>), and each one imports the app in its own server (<a href="https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/server.py#L96-L99">server.py</a>). As with Gunicorn, the worker count defaults to <code>WEB_CONCURRENCY</code> when you don't pass <code>--workers</code> (<a href="https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/config.py#L358-L359">config.py</a>). Moving <code>scheduler.start()</code> from module level into a FastAPI or Starlette lifespan handler doesn't help: the lifespan is created in that same per-worker server, right after the app loads.</p>
<p>With <code>--reload</code> or more than one worker, the Uvicorn parent process doesn't load the app at all; only the children do (<a href="https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/main.py#L614-L620">main.py</a>). Under <code>uvicorn --reload</code>, you therefore have one scheduler at a time, replaced on each reload.</p>
<h2>What --preload Does to the Scheduler Thread</h2>
<p>With <code>--preload</code>, Gunicorn loads the app in the master process before forking any workers (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/arbiter.py#L163-L164">arbiter.py</a>), a setting its docs describe as a way to "save some RAM resources as well as speed up server boot times" (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L1101-L1115">config.py</a>). A scheduler started at import now starts its thread in the master. Workers are created with <code>os.fork()</code> (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/arbiter.py#L734">arbiter.py</a>), and POSIX specifies that the child of a multi-threaded process contains "a replica of the calling thread" only (<a href="https://pubs.opengroup.org/onlinepubs/9799919799/functions/fork.html">fork</a>).</p>
<p>The result looks like a fix and isn't one:</p>
<ul>
<li><strong>Jobs run only in the master</strong>, because only the master has a scheduler thread. The duplicates disappear by accident.</li>
<li><strong>Each worker holds a copy of the scheduler with no thread behind it.</strong> Its state was copied from the master, so it still reports itself as started. A job a request handler adds with <code>add_job()</code> goes into that worker's copy, where nothing will run it. With a persistent store, the row reaches the database, but the master's scheduler isn't notified, per the FAQ above.</li>
<li><strong>Every fork is a fork of a multi-threaded process.</strong> Since Python 3.12, <code>os.fork()</code> raises a <code>DeprecationWarning</code> when it detects other threads, and the docs say "it has never been safe to mix threading with <code>os.fork()</code> on POSIX platforms" (<a href="https://docs.python.org/3/library/os.html#os.fork">os.fork</a>). Gunicorn forks again every time it replaces a worker.</li>
</ul>
<p>Treat <code>--preload</code> as a memory setting. If the scheduler belongs in the master, it belongs in its own process instead.</p>
<h2>Development Reloaders Start a Second Scheduler</h2>
<p>These cases run two schedulers on a laptop even when production runs one.</p>
<h3>Flask: flask run --debug and app.run(debug=True)</h3>
<p><code>flask run --debug</code> turns the reloader on, because <code>--reload</code> defaults to the debug flag (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L983-L984">cli.py</a>). The command loads your app before it starts the server (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L963">cli.py</a>). Werkzeug's reloader then starts a new interpreter with the same arguments and <code>WERKZEUG_RUN_MAIN=true</code>, waits for it, and starts another when a file change makes it exit with code 3 (<a href="https://github.com/pallets/werkzeug/blob/3.1.9/src/werkzeug/_reloader.py#L266-L278">_reloader.py</a>). That child loads the app again.</p>
<p>So a scheduler started at import or in <code>create_app()</code> runs in the parent for the whole session and in the child, with the child's copy replaced on every reload. <code>app.run(debug=True)</code> behaves the same way, because <code>use_reloader</code> defaults to the debug setting (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/app.py#L653">app.py</a>).</p>
<p>Werkzeug exposes the check as <code>is_running_from_reloader()</code>, which tests that environment variable (<a href="https://github.com/pallets/werkzeug/blob/3.1.9/src/werkzeug/serving.py#L955-L961">serving.py</a>). <code>--debug</code> sets <code>FLASK_DEBUG</code> early so that factories can read it (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L468-L481">cli.py</a>), and the app's <code>DEBUG</code> config starts from that value (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/sansio/app.py#L495">sansio/app.py</a>):</p>
<pre><code class="language-python">from flask import Flask
from werkzeug.serving import is_running_from_reloader


def create_app():
    app = Flask(__name__)
    # ... register blueprints and jobs ...
    if not app.debug or is_running_from_reloader():
        scheduler.start()
    return app
</code></pre>
<p>Under <code>flask run --debug</code>, or with <code>FLASK_DEBUG=1</code> set, this skips the reloader parent and nothing else. It makes the scheduler wait for the reloader in debug mode, so <code>flask run --debug --no-reload</code> starts no scheduler at all. It does nothing about Gunicorn workers, where <code>app.debug</code> is false and every worker passes the check.</p>
<p>The guard doesn't work with <code>app.run(debug=True)</code>. <code>get_debug_flag()</code> reads only <code>FLASK_DEBUG</code> (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/helpers.py#L28-L33">helpers.py</a>), and <code>app.run()</code> sets <code>app.debug</code> from its <code>debug</code> argument only when it is called (<a href="https://github.com/pallets/flask/blob/3.1.3/src/flask/app.py#L630-L632">app.py</a>), after <code>create_app()</code> has returned. The factory sees <code>app.debug</code> as false in both processes, and both start a scheduler. <code>flask run --reload</code> without <code>--debug</code> fails the same way. For <code>app.run(debug=True)</code>, set <code>FLASK_DEBUG=1</code> in the environment before the script starts, or pass <code>use_reloader=False</code>.</p>
<h3>Django: runserver and AppConfig.ready()</h3>
<p>Unless you pass <code>--noreload</code>, <code>manage.py runserver</code> runs <code>django.setup()</code> in the parent process first (<a href="https://github.com/django/django/blob/6.1.2/django/core/management/__init__.py#L393-L395">management/__init__.py</a>), which calls every <code>AppConfig.ready()</code>. The autoreloader then runs the command again in a child with <code>RUN_MAIN=true</code> (<a href="https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L273-L274">autoreload.py</a>, <a href="https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L681-L692">autoreload.py</a>), and <code>ready()</code> runs again there. A scheduler started in <code>ready()</code> runs twice.</p>
<p>It also starts in places you didn't intend. Django's docs warn that <code>ready()</code> "will run during startup of every management command" (<a href="https://docs.djangoproject.com/en/6.1/ref/applications/#django.apps.AppConfig.ready">applications</a>), so <code>migrate</code>, <code>shell</code>, and <code>collectstatic</code> each start a scheduler too, one that lasts as long as the command does.</p>
<p>Checking <code>os.environ.get("RUN_MAIN") == "true"</code> skips the parent, but <code>RUN_MAIN</code> is an internal name in <code>django.utils.autoreload</code> (<a href="https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L27">autoreload.py</a>), not a documented setting, and it isn't set under Gunicorn at all. The durable fix is to take the scheduler out of <code>ready()</code> and start it from a management command run as its own process, the <a href="#a-dedicated-scheduler-process">dedicated scheduler process</a> below. <a href="https://cronradar.com/blog/django-cron-monitoring#can-i-use-django-apscheduler-with-gunicorn">Django cron monitoring</a> gives the same advice for django-apscheduler.</p>
<h2>The Fixes, and What Each Costs</h2>
<p>Every fix trades duplicate runs for a different failure: zero schedulers. Decide which you would rather detect.</p>
<h3>A Dedicated Scheduler Process</h3>
<p>This is the FAQ's workaround: "Run the scheduler in a dedicated process" (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L85-L87">faq.rst</a>). Use <code>BlockingScheduler</code>, which the user guide recommends "when the scheduler is the only thing running in your process" (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L66-L67">userguide.rst</a>), in an entry point such as <code>python -m myapp.scheduler</code>, deployed beside the web service. Remove every <code>start()</code> call from the web app. <a href="https://cronradar.com/blog/python-scheduler-monitoring#monitor-a-scheduled-python-job-with-cronradar">Python scheduler monitoring</a> has a complete worker of this kind.</p>
<p>What it costs:</p>
<ul>
<li><strong>One more process to deploy, supervise, and restart.</strong> If it's down, nothing runs and nothing errors.</li>
<li><strong>No more <code>add_job()</code> from request handlers.</strong> The web app can't reach the scheduler's in-memory jobs. The FAQ suggests a remote access mechanism such as RPyC, gRPC, or an HTTP server; many teams define jobs in code instead.</li>
<li><strong>It must stay at one copy.</strong> Scaling the scheduler service to two replicas brings the duplicates back. <a href="https://cronradar.com/blog/distributed-cron#option-1-one-scheduler-process-restarted-by-the-orchestrator">Distributed cron</a> covers why a one-replica Deployment can briefly be two during a rolling update.</li>
</ul>
<h3>One Web Worker Wins a Lock</h3>
<p>Keep the scheduler inside the web app, and let only the worker that gets an exclusive lock start it. On a single host, <code>fcntl.flock</code> with <code>LOCK_NB</code> fails immediately with an <code>OSError</code> instead of waiting (<a href="https://docs.python.org/3/library/fcntl.html#fcntl.flock">fcntl</a>):</p>
<pre><code class="language-python">import fcntl
import logging
import os

log = logging.getLogger("scheduler")
_lock_file = None  # Keep the file open: closing it releases the lock.


def start_scheduler_if_first(scheduler, lock_path):
    global _lock_file
    lock_file = open(lock_path, "w")
    try:
        fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        lock_file.close()
        log.info("pid=%s: another process runs the scheduler", os.getpid())
        return False
    _lock_file = lock_file
    scheduler.start()
    log.info("pid=%s: running the scheduler", os.getpid())
    return True
</code></pre>
<p>Call it where you used to call <code>scheduler.start()</code>. The kernel releases the lock when every descriptor for that open file is closed, including when the process exits (<a href="https://man7.org/linux/man-pages/man2/flock.2.html">flock(2)</a>), so a crashed holder can't leave a stale lock.</p>
<p>What it costs:</p>
<ul>
<li><strong>One scheduler per host, not per deployment.</strong> Each machine or container has its own lock file. Two app servers mean two schedulers.</li>
<li><strong>The scheduler lives and dies with one web worker.</strong> When Gunicorn restarts the holder, for <code>--max-requests</code> or a timeout, the scheduler goes with it. It comes back only when a newly booted worker takes the lock. Workers that lost the race at startup don't try again.</li>
<li><strong>Take the lock after the fork.</strong> Under <code>--preload</code>, this code runs once, in the master, and flock(2) notes that descriptors duplicated by <code>fork(2)</code> "refer to the same lock," so every worker shares the master's.</li>
</ul>
<p>Across hosts, PostgreSQL's <code>pg_try_advisory_lock(key bigint)</code> does the same thing: it "will either obtain the lock immediately and return true, or return false without waiting" (<a href="https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS">advisory lock functions</a>). A session-level advisory lock "is held until explicitly released or the session ends" (<a href="https://www.postgresql.org/docs/18/explicit-locking.html#ADVISORY-LOCKS">advisory locks</a>), so the winning worker must keep that connection open for its whole life, outside any pool that might close or reuse it. If the connection drops, the lock is released while the scheduler thread keeps running, and the next worker to boot can win it too.</p>
<h3>Run One Web Worker</h3>
<p>A single web worker also gives you one scheduler per host: <code>-w 1</code> for Gunicorn, <code>--workers 1</code> for Uvicorn. Pass the count explicitly, because without the flag both servers use <code>WEB_CONCURRENCY</code> when it is set (<a href="https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L717-L736">Gunicorn</a>, <a href="https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/config.py#L358-L359">Uvicorn</a>). The price is serving all traffic from one process, and the scheduler still restarts whenever that worker restarts.</p>
<table>
<thead>
<tr>
<th>Fix</th>
<th>Removes</th>
<th>Costs</th>
</tr>
</thead>
<tbody>
<tr>
<td>Dedicated <code>BlockingScheduler</code> process</td>
<td>All duplicates, on any number of web hosts</td>
<td>One more service; no runtime <code>add_job()</code> from the web app</td>
</tr>
<tr>
<td><code>flock</code> lock in each worker</td>
<td>Duplicates on one host</td>
<td>One scheduler per host; restarts with its worker</td>
</tr>
<tr>
<td>PostgreSQL advisory lock</td>
<td>Duplicates across hosts</td>
<td>A connection held open for life; a dropped one can allow a second</td>
</tr>
<tr>
<td>Reloader guard</td>
<td>The dev reloader's extra scheduler</td>
<td>Nothing in production</td>
</tr>
<tr>
<td>One web worker</td>
<td>Duplicates on one host</td>
<td>All requests on one process</td>
</tr>
</tbody>
</table>
<h2>When One Scheduler Runs the Job Twice</h2>
<p><code>max_instances</code>, <code>coalesce</code>, and <code>misfire_grace_time</code> act inside one scheduler. None of them can see another process.</p>
<p><strong><code>max_instances</code></strong> (default 1) is counted in a dictionary held by each executor (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/executors/base.py#L34">executors/base.py</a>, <a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/executors/base.py#L71-L72">submit check</a>). It stops a slow run from overlapping the next one in the same scheduler. Four workers with <code>max_instances=1</code> can still run four copies at once.</p>
<p><strong>Catch-up after a restart</strong> is the same-PID, back-to-back pattern. It needs a job that stayed in a persistent store while the scheduler was down and wasn't added again at startup: adding it again with <code>replace_existing=True</code> overwrites the stored row with a next run time computed from now (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L1067-L1081">base.py</a>). Even then, the 3.x default <code>coalesce=True</code> (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L911-L913">base.py</a>) submits only the latest missed time (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L1192-L1194">base.py</a>), so a burst of runs means <code>coalesce=False</code> together with a long <code>misfire_grace_time</code> or <code>misfire_grace_time=None</code>. <a href="https://cronradar.com/blog/missed-runs-after-downtime#apschedulers-defaults-drop-the-missed-run">Missed runs after downtime</a> covers the defaults and whether catching up is what you want.</p>
<p><strong>Copies of the job</strong> are the pattern that grows with each deploy. A job added without an <code>id</code> gets a random one (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/job.py#L65">job.py</a>). In a persistent store, adding it at startup therefore adds a new row each time, which the user guide flags in bold: you "<strong>MUST</strong> define an explicit ID for the job and use <code>replace_existing=True</code> or you will get a new copy of the job every time your application restarts!" (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L279-L281">userguide.rst</a>). Add both, then remove the old copies, which <code>scheduler.get_jobs()</code> lists under their random IDs.</p>
<p>Even with one scheduler, a run can repeat after a crash or a lock that changes hands. <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">Idempotent scheduled jobs</a> shows how to make a second run of the same occurrence do nothing.</p>
<h2>Report Each Run to CronRadar</h2>
<p>Python SDK connections are temporarily unavailable (<a href="https://docs.cronradar.com/integrations/python">Python integration</a>), so each job reports with HTTP check-ins. HTTP check-ins don't discover jobs automatically; give each job its own monitor key and its real schedule. Keep the API key in a <code>CRONRADAR_API_KEY</code> environment variable from your deployment's secret store, with a separate CronRadar application and key for each environment.</p>
<p><a href="https://cronradar.com/blog/python-scheduler-monitoring#monitor-a-scheduled-python-job-with-cronradar">Python scheduler monitoring</a> has the standard-library check-in helper, which uses HTTP Basic auth with the key as username and an empty password, a five-second timeout, and logs monitoring errors instead of raising them. It also has a wrapper that reports a start, then a completion or a failure, and re-raises the job's exception. For duplicate runs, change only how that wrapper builds its <code>run_id</code>: include the host and PID, so each run in the monitor's history shows which process ran it.</p>
<pre><code class="language-python">import os
import socket
import uuid


def new_run_id():
    # At most 128 characters; identifies the process, never the job's data.
    return f"{socket.gethostname()[:60]}-{os.getpid()}-{uuid.uuid4().hex[:12]}"
</code></pre>
<p>In that post's <code>monitored_backup()</code>, replace <code>run_id = uuid.uuid4().hex</code> with <code>run_id = new_run_id()</code>. The wrapper already sends the same <code>run_id</code> with the start and with the completion or failure, and the API accepts up to 128 characters for it (<a href="https://docs.cronradar.com/api-reference">API reference</a>).</p>
<p>If one expression feeds both <code>CronTrigger.from_crontab()</code> and the monitor's <code>schedule</code>, which CronRadar reads as five-field cron in UTC (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>), avoid numeric weekdays: <code>from_crontab()</code> in APScheduler 3.x treats 0 as Monday, while standard cron treats it as Sunday (<a href="https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/triggers/cron/__init__.py#L129-L134">cron trigger</a>).</p>
<p>What this shows, and what it doesn't:</p>
<ul>
<li><strong>Duplicates are visible, not alerted.</strong> CronRadar records every run it receives and doesn't alert on extra runs. Each <code>run_id</code> appears as its own run in the monitor's history, with its Execution ID, so four runs at the same scheduled time from four PIDs is the per-worker pattern above.</li>
<li><strong>Zero schedulers is alerted.</strong> This is the failure every fix above introduces: a dedicated process that didn't restart, or a lock holder that was recycled. No <code>start</code> arrives, the monitor shows <code>warning</code> once the scheduled time passes, and <code>critical</code> after the grace period, with an alert by email, Slack, webhook, Discord, or PagerDuty.</li>
<li><strong>A failed run alerts immediately.</strong> <code>fail</code> records the result <code>failed</code> and opens an incident.</li>
</ul>
<p>Don't send an <code>Idempotency-Key</code> derived from the scheduled time to collapse the duplicates. A repeated key returns the original ping without reapplying state (<a href="https://docs.cronradar.com/api-reference">API reference</a>), so the dashboard would show one run while the job still ran twice.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why does my APScheduler job run once per Gunicorn worker?</h3>
<p>Without <code>--preload</code>, each Gunicorn worker loads your app after it is forked, so a scheduler started at import or in an app factory starts once per worker: <code>-w 4</code> means four schedulers. Without <code>-w</code>, the count comes from <code>WEB_CONCURRENCY</code> when it is set. Log <code>os.getpid()</code> when the scheduler starts to count them.</p>
<h3>How do I run APScheduler with Gunicorn and multiple workers?</h3>
<p>Run the scheduler outside Gunicorn, as one <code>BlockingScheduler</code> process, which is what the APScheduler FAQ recommends. If it must live in the web app, start it only in the worker that wins an exclusive lock, and accept that it restarts whenever that worker does.</p>
<h3>Does gunicorn --preload fix duplicate APScheduler jobs?</h3>
<p>It hides them. The scheduler thread starts in the master, and forked workers don't get a copy of it, so jobs run only in the master. Each later fork is a fork of a multi-threaded process, which Python's documentation calls unsafe, and a job added from a worker goes into a scheduler copy with no thread to run it.</p>
<h3>Why does APScheduler run twice with flask run --debug?</h3>
<p>The debug reloader loads your app in a parent process, then again in a child process started with <code>WERKZEUG_RUN_MAIN=true</code>. With <code>flask run --debug</code>, start the scheduler only when <code>not app.debug or is_running_from_reloader()</code>, or run a dedicated scheduler process. That check fails under <code>app.run(debug=True)</code>, where <code>app.debug</code> is still false when the factory runs; there, set <code>FLASK_DEBUG=1</code> before the script starts or pass <code>use_reloader=False</code>.</p>
<h3>Does max_instances=1 stop duplicate runs?</h3>
<p>Only inside one scheduler. Each process counts its own running instances, so it stops overlap of a slow run but not a second process starting the same job.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>apscheduler running twice</category>
      <category>apscheduler gunicorn multiple workers</category>
      <category>apscheduler flask debug reloader</category>
      <category>apscheduler duplicate jobs</category>
      <category>apscheduler django runserver</category>
    </item>
    <item>
      <title><![CDATA[Better Stack Heartbeats vs a Dedicated Cron Monitor: Period, Grace, Pricing]]></title>
      <link>https://cronradar.com/blog/better-stack-heartbeats</link>
      <guid>https://cronradar.com/blog/better-stack-heartbeats</guid>
      <description><![CDATA[How Better Stack heartbeats work: period and grace in seconds, the secret URL and /fail, no cron expressions, pricing, and how CronRadar compares.]]></description>
      <content:encoded><![CDATA[<p><strong>A Better Stack heartbeat is a secret URL your job requests on each run; when no request arrives within the heartbeat's period plus its grace period, Better Stack opens an incident and alerts the on-call team.</strong> The period is a number of seconds, not a cron expression, and a job reports a failure by appending <code>/fail</code> or its exit code to the URL. A dedicated cron monitor such as CronRadar reads the job's cron schedule instead and accepts start, complete, and fail check-ins with optional outcome values; Better Stack is the better fit when heartbeats should page the same on-call rotation as the rest of your incidents.</p>
<p>This guide covers the Better Stack Terraform provider 0.22.4, the current release on the <a href="https://registry.terraform.io/providers/BetterStackHQ/better-uptime/0.22.4">Terraform Registry</a> (checked October 30, 2026), and Better Stack's hosted heartbeats as its documentation, API reference, and pricing page described them on that date. It compares heartbeat features only, not Better Stack's uptime checks, status pages, or telemetry products. For other options, see <a href="https://cronradar.com/blog/sentry-crons-vs-dedicated-cron-monitoring">Sentry Crons vs a dedicated cron monitor</a>, <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch vs CronRadar</a>, and <a href="https://cronradar.com/blog/uptime-kuma-cron-monitoring">Uptime Kuma push monitors</a>.</p>
<h2>How a Better Stack Heartbeat Is Configured</h2>
<p>In the dashboard you go to Heartbeats → Create heartbeat, give it a name, set <strong>Expect a heartbeat every</strong> to the task's frequency, set the <strong>grace period</strong>, "the extra time allowed before an incident is raised if the heartbeat is missed," choose on-call escalation settings, and then copy the secret URL from the heartbeat's detail page (<a href="https://betterstack.com/docs/uptime/cron-and-heartbeat-monitor/">heartbeat guide</a>).</p>
<p>The same settings are fields of the <a href="https://betterstack.com/docs/uptime/api/create-a-hearbeat/">Create a heartbeat</a> API, <code>POST https://incidents.betterstack.com/api/v2/heartbeats</code> with a bearer token:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>What it controls</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>period</code></td>
<td>Required. How often to expect a heartbeat, in seconds: at least 30, and shorter than one year</td>
</tr>
<tr>
<td><code>grace</code></td>
<td>Required. How late a heartbeat may arrive, in seconds, before it counts as missed: at least 0, shorter than one year. Better Stack recommends about 20% of <code>period</code></td>
</tr>
<tr>
<td><code>server_timezone</code></td>
<td>Optional IANA time zone, such as <code>Europe/Berlin</code>, for DST-aware missed-beat detection. Requires a <code>period</code> of at least 3,600 seconds</td>
</tr>
<tr>
<td><code>email</code>, <code>sms</code>, <code>call</code>, <code>push</code>, <code>critical_alert</code></td>
<td>How the on-call person is notified. Only <code>email</code> defaults to <code>true</code>; <code>critical_alert</code> is a push that ignores the mute switch and Do Not Disturb</td>
</tr>
<tr>
<td><code>team_wait</code></td>
<td>Seconds before the incident escalates to the whole team; leave it blank to never escalate to the team</td>
</tr>
<tr>
<td><code>policy_id</code></td>
<td>An escalation policy. Without one, Better Stack emails the primary on-call, escalates to the rest of the team if nobody acknowledges, and triggers all of your integrations (<a href="https://betterstack.com/docs/uptime/escalation-policies/">escalation policies</a>)</td>
</tr>
<tr>
<td><code>maintenance_from</code>, <code>maintenance_to</code>, <code>maintenance_days</code>, <code>maintenance_timezone</code></td>
<td>A daily maintenance window on chosen weekdays, in a Rails time zone name, UTC by default</td>
</tr>
<tr>
<td><code>heartbeat_group_id</code>, <code>metadata</code>, <code>paused</code></td>
<td>A heartbeat group; metadata such as an owner or environment, settable only at creation and changed later through the metadata API; and pausing</td>
</tr>
</tbody>
</table>
<p>None of these fields is a schedule. The heartbeat knows how often to expect a request and how long to wait, nothing about which days or times the job runs.</p>
<h3>Pending, Up, Down, and Validating</h3>
<p>A new heartbeat stays Pending until its first request arrives. The guide says "the monitoring period begins from the first heartbeat received, and incidents are only raised after the expected frequency and grace period have elapsed" (<a href="https://betterstack.com/docs/uptime/cron-and-heartbeat-monitor/">heartbeat guide</a>). A heartbeat for a job that was never deployed therefore never starts its monitoring period, and the guide describes no alert for that case.</p>
<p>After that, the API's <code>status</code> field reports <code>up</code> when requests arrive on time, <code>down</code> when one didn't, <code>validating</code> when requests arrive on time again and the incident waits to be resolved, <code>maintenance</code> inside the maintenance window, and <code>paused</code> (<a href="https://betterstack.com/docs/uptime/api/get-a-single-hearbeat/">Get a single heartbeat</a>). An incident stops alerting other team members once someone acknowledges it (<a href="https://betterstack.com/docs/uptime/acknowledging-an-incident/">acknowledging</a>).</p>
<h3>Heartbeats in Terraform</h3>
<p>The provider's <code>betteruptime_heartbeat</code> resource takes the same settings except <code>metadata</code> and exposes the heartbeat URL as the computed <code>url</code> attribute (<a href="https://github.com/BetterStackHQ/terraform-provider-better-uptime/blob/v0.22.4/docs/resources/betteruptime_heartbeat.md">resource docs</a>). The provider reads its token from <code>BETTERUPTIME_API_TOKEN</code> when <code>api_token</code> is omitted (<a href="https://github.com/BetterStackHQ/terraform-provider-better-uptime/blob/v0.22.4/internal/provider/provider.go#L40-L46">provider.go</a>):</p>
<pre><code class="language-terraform">terraform {
  required_providers {
    betteruptime = {
      source  = "BetterStackHQ/better-uptime"
      version = "0.22.4"
    }
  }
}

# The API token comes from the BETTERUPTIME_API_TOKEN environment variable.
provider "betteruptime" {}

resource "betteruptime_heartbeat" "nightly_backup" {
  name   = "Nightly database backup"
  period = 86400 # one request a day
  grace  = 3600  # up to an hour late before an incident
}

output "nightly_backup_heartbeat_url" {
  value     = betteruptime_heartbeat.nightly_backup.url
  sensitive = true
}
</code></pre>
<p>In 0.22.4 the <code>url</code> attribute isn't marked sensitive (<a href="https://github.com/BetterStackHQ/terraform-provider-better-uptime/blob/v0.22.4/internal/provider/resource_heartbeat.go#L26-L31">resource_heartbeat.go</a>), so mark any output that exposes it <code>sensitive = true</code>, which redacts it in the CLI's apply output. Terraform still stores it in state, and <code>terraform output -raw</code> prints it in plain text (<a href="https://developer.hashicorp.com/terraform/language/manage-sensitive-data">Terraform docs</a>), which is how you'd read it to install on the job host.</p>
<h2>The Heartbeat URL Is the Secret</h2>
<p>A heartbeat URL has the form <code>https://incidents.betterstack.com/api/v1/heartbeat/&#x3C;HEARTBEAT_TOKEN></code>, and the guide calls it a secret URL. The documented requests carry no other credential, so anyone who has the URL can mark the job as having run or report it failed. Keep it out of crontab lines, scripts in version control, and job logs: store it in a file readable only by the job's account, or in your platform's secret store.</p>
<p>The guide documents three requests (<a href="https://betterstack.com/docs/uptime/cron-and-heartbeat-monitor/#reporting-failures">heartbeat guide</a>):</p>
<ul>
<li><strong>The URL itself</strong> records a heartbeat. The guide's example is a plain <code>curl</code> at the end of the job.</li>
<li><strong>The URL plus <code>/fail</code></strong> explicitly reports a failure.</li>
<li><strong>The URL plus the exit code</strong>, with the job's output sent as the request body, reports a failure with both, "to help diagnose the failure."</li>
</ul>
<p>It documents no start request and no run duration, so a run that hangs sends nothing and shows up as a missed heartbeat once the period and grace pass.</p>
<p>This wrapper sends the plain request after a successful run, and the exit code with the output's last 10 KB after a failed one. The guide states no size limit for the body; the 10 KB cut is this script's choice. Each request gives up after five seconds, a failed request is ignored, and the script exits with the job's own code:</p>
<pre><code class="language-bash">#!/bin/sh
# /opt/backup-betterstack.sh: run the backup, then report to a Better Stack heartbeat.
# The file holds only the heartbeat URL and is readable only by this account.
heartbeat_url=$(cat /etc/betterstack/nightly-backup.url 2>/dev/null)

report() {
  [ -n "$heartbeat_url" ] || return 0
  curl --fail --silent --show-error --max-time 5 --output /dev/null "$@" || true
}

output=$(/opt/backup.sh 2>&#x26;1)
rc=$?
printf '%s\n' "$output"

if [ "$rc" -eq 0 ]; then
  report "$heartbeat_url"
else
  # The exit code in the path reports a failure; the body carries the output.
  printf '%s' "$output" | tail -c 10000 | report --data-binary @- "$heartbeat_url/$rc"
fi
exit "$rc"
</code></pre>
<p>Schedule it with <code>0 3 * * * /opt/backup-betterstack.sh</code>. With <code>--data-binary @-</code>, curl reads the POST body from standard input and keeps its newlines, and output that happens to start with <code>@</code> isn't taken for a file name, as it would be with <code>-d "$output"</code> (<a href="https://curl.se/docs/manpage.html#--data-binary">curl manpage</a>). The backup's output is printed again so cron's mail or your log collector still gets it.</p>
<h2>A Period Is Not a Cron Schedule</h2>
<p>Because the heartbeat expects one request per period, it fits jobs that run at a fixed interval. Schedules that aren't a fixed interval need workarounds:</p>
<ul>
<li><strong>Weekdays only.</strong> <code>0 3 * * 1-5</code> has gaps of one day and three days, and one period can't match both. The guide says only that "the monitoring period begins from the first heartbeat received"; it doesn't say whether each later deadline is measured from the last request, so test how a weekday heartbeat behaves over a weekend before relying on it. <a href="https://cronradar.com/blog/cron-monitoring-vs-uptime-monitoring">Cron monitoring vs uptime monitoring</a> works through what a fixed interval costs on a weekday job. Per-weekday maintenance windows exist, but the docs don't say what happens to a heartbeat that falls due inside one once the window ends, so test that too.</li>
<li><strong>Several fixed times a day,</strong> such as 06:00 and 18:00, are a fixed interval only when they're evenly spaced; <code>0 6,20 * * *</code> has gaps of 14 and 10 hours, and one period can't match both.</li>
<li><strong>Monthly jobs</strong> run on calendar dates, and months aren't a fixed number of seconds.</li>
</ul>
<p>For daylight saving time, Better Stack is ahead. Set <code>server_timezone</code> and the period is evaluated "against wall-clock time, keeping daily and cron-style schedules aligned across daylight saving time changes," for periods of an hour or more (<a href="https://github.com/BetterStackHQ/terraform-provider-better-uptime/blob/v0.22.4/internal/provider/resource_heartbeat.go#L44-L48">resource_heartbeat.go</a>). CronRadar evaluates every schedule in UTC. A backup at 03:00 Europe/Berlin runs at 01:00 UTC in summer and 02:00 UTC in winter, so CronRadar's schedule docs say to use the earlier time, <code>0 1 * * *</code>, with a grace of at least an hour plus the run time (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>). In summer, that extra hour delays every missed-run alert. <a href="https://cronradar.com/blog/cron-daylight-saving-time">Cron and daylight saving time</a> covers the general problem.</p>
<p>On grace: Better Stack's 20% suggestion is 17,280 seconds, close to five hours, for a daily job. The request goes out when the job finishes, so the grace has to cover the start delay plus how much the run time varies; anything beyond that only delays the alert. <a href="https://cronradar.com/blog/cron-grace-period">Choosing a grace period</a> shows how to size it from measured runs.</p>
<h2>Better Stack Heartbeat Pricing</h2>
<p>From the <a href="https://betterstack.com/pricing">pricing page</a>, checked October 30, 2026:</p>
<ul>
<li><strong>Free for personal projects:</strong> $0 a month, listing "10 monitors &#x26; heartbeats, 1 status page" and "Slack &#x26; e-mail alerts."</li>
<li><strong>Heartbeats:</strong> "10 heartbeats included," then each additional 10 heartbeats cost $20 a month, or $17 a month billed annually.</li>
<li><strong>Responder license:</strong> $34 per license per month, or $29 billed annually, which the page describes as "Access to Incidents, including on-call, monitoring and status pages, with unlimited phone call and SMS alerts." The paid side of the page "starts at just $34" and "includes a single responder."</li>
</ul>
<p>Better Stack's roles page says Admin, Team lead, and Responder each consume a responder license, and the free Member role can't access monitors or heartbeats (<a href="https://betterstack.com/docs/uptime/team-management/manage-roles/">managing roles</a>). With the default roles, everyone who needs to see or edit heartbeats uses a responder license.</p>
<p>The pricing page doesn't total a heartbeat-only bill, so the figures below add list prices on monthly billing, assuming one responder license is the minimum paid setup. Confirm the complete quote with Better Stack. CronRadar's columns assume every job is in one schedule class, with unlimited collaborators:</p>
<table>
<thead>
<tr>
<th align="right">Jobs</th>
<th align="right">Better Stack, 1 responder</th>
<th align="right">Better Stack, 5 responders</th>
<th align="right">CronRadar, hourly or slower</th>
<th align="right">CronRadar, every 5–59 minutes</th>
<th align="right">CronRadar, under 5 minutes</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">10</td>
<td align="right">$34</td>
<td align="right">$170</td>
<td align="right">$0</td>
<td align="right">$0</td>
<td align="right">$0</td>
</tr>
<tr>
<td align="right">25</td>
<td align="right">$74</td>
<td align="right">$210</td>
<td align="right">$7.50</td>
<td align="right">$15</td>
<td align="right">$30</td>
</tr>
<tr>
<td align="right">50</td>
<td align="right">$114</td>
<td align="right">$250</td>
<td align="right">$20</td>
<td align="right">$40</td>
<td align="right">$80</td>
</tr>
<tr>
<td align="right">100</td>
<td align="right">$214</td>
<td align="right">$350</td>
<td align="right">$45</td>
<td align="right">$90</td>
<td align="right">$180</td>
</tr>
</tbody>
</table>
<p>The license buys more than heartbeats: the same seat covers on-call, monitoring, and status pages. Per job, Better Stack's block rate is $2 a heartbeat monthly or $1.70 annually, so for jobs that run more often than every 5 minutes, annual billing comes in below CronRadar's $2 Fast price once the responder licenses are already paid for.</p>
<p>CronRadar's columns use its list prices: Free covers up to 10 active monitors, and on Grow the first 10 stay free and each further monitor costs $2 Fast (gap under 5 minutes), $1 Frequent (5 minutes to under an hour), or $0.50 Standard (hourly or less often) a month, billed in arrears on the month's peak (<a href="https://cronradar.com/pricing">pricing</a>).</p>
<h2>How CronRadar Monitors the Same Job</h2>
<p>CronRadar is a hosted monitor for scheduled jobs only. Four things differ from a Better Stack heartbeat (<a href="https://docs.cronradar.com/concepts">concepts</a>); <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch vs CronRadar</a> covers in more detail how CronRadar decides a run is late, stuck, or never ran:</p>
<ul>
<li><strong>A schedule instead of a period.</strong> A monitor holds a standard five-field cron expression evaluated in UTC, plus a <code>gracePeriod</code> in seconds, minimum 60 and default 90. Every check-in moves the expected time to the schedule's next occurrence, so <code>0 3 * * 1-5</code> expects nothing on weekends.</li>
<li><strong>Start, duration, and stuck runs.</strong> Besides a plain heartbeat, a job can send <code>start</code>, then <code>complete</code> or <code>fail</code>, paired by a <code>run_id</code>. <code>start</code> and <code>complete</code> record the run's duration, and <code>fail</code> opens an incident immediately with an optional <code>message</code> of up to 1,000 characters. A run that sent <code>start</code> and hangs goes <code>critical</code> as stuck only when the next expected run plus grace passes, about a day later for a daily job, which is later than a success-only heartbeat goes missing; a timeout inside the job turns a hang into a prompt <code>fail</code> (<a href="https://cronradar.com/blog/cron-job-timeout">cron job timeouts</a>).</li>
<li><strong>Outcome checks instead of output.</strong> A <code>complete</code> sent by POST can carry up to five numeric or boolean values. Once expectations such as <code>dump_bytes >= 1</code> are configured in the job's settings, a missing or violated value marks the run as an outcome failure (<code>outcomefailed</code>) and opens an incident. That's the <a href="https://cronradar.com/blog/scheduled-job-silent-success">run that exits 0 and does nothing</a>, which a heartbeat records as a success.</li>
<li><strong>An alert for a job that never ran.</strong> A monitor created in the dashboard, by Hangfire discovery, or by Quartz.NET discovery for a job on a repeating simple trigger, is <code>pending</code> until its first expected run plus grace passes, then turns <code>critical</code> and sends a never-executed alert. A monitor created by its own first check-in can't flag a job that never runs, the same gap a Better Stack heartbeat has.</li>
</ul>
<p>The credential is handled differently too. With Basic auth, as used here, a CronRadar check-in URL holds only the job's monitor key, and the application's API key goes in the Authorization header as the username with an empty password (<a href="https://docs.cronradar.com/api-reference">HTTP API</a>). That key can report for every job in its application, so it needs the same care as a heartbeat URL, but it stays out of URLs, proxy logs, and shell history.</p>
<p>The <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch guide</a> has a complete CronRadar wrapper for this same 03:00 backup: it reads the API key from a file only the job's account can read, sends <code>start</code>, then <code>complete</code> or <code>fail</code>, gives up on each request after five seconds, ignores errors, and exits with the backup's own code. To also check that the dump isn't empty, drop its Dead Man's Snitch line and replace its <code>report complete</code> line with a <code>complete</code> sent by POST that carries the dump's size:</p>
<pre><code class="language-bash">  # In the wrapper's success branch, in place of "report complete":
  dump_bytes=$(wc -c 2>/dev/null &#x3C; /var/backups/db-latest.dump || echo 0)
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --user "$CRONRADAR_API_KEY:" \
    --header "Content-Type: application/json" \
    --data "{\"outcomes\":{\"dump_bytes\":$dump_bytes}}" \
    "$url/complete?run_id=$run_id" || true
</code></pre>
<p>The <code>dump_bytes</code> value is stored only after you configure an expectation for that key; unconfigured keys are accepted and dropped (<a href="https://docs.cronradar.com/api-reference">HTTP API</a>).</p>
<p>For .NET, the <code>CronRadar</code> 1.0.13 package wraps a job with <code>WrapAsync</code> but can't send outcome values; that needs the pending 1.0.14 release. <code>CronRadar.Hangfire</code> 0.1.0 and <code>CronRadar.Quartz</code> 0.1.0 discover recurring jobs, though Quartz 0.1.0 monitors only jobs on repeating simple triggers; <a href="https://cronradar.com/blog/hangfire-monitoring">Hangfire monitoring</a> and <a href="https://cronradar.com/blog/quartz-net-monitoring">Quartz.NET monitoring</a> cover them, including the published Hangfire 0.1.0's retry caveat. The Node.js, Python, Celery, PHP, and Laravel SDKs aren't installable, so those jobs report over HTTP as above, and HTTP check-ins don't discover jobs automatically.</p>
<h2>Better Stack and CronRadar Side by Side</h2>
<p>Better Stack facts checked October 30, 2026, from the pages linked above.</p>
<table>
<thead>
<tr>
<th></th>
<th>Better Stack heartbeat</th>
<th>CronRadar</th>
</tr>
</thead>
<tbody>
<tr>
<td>When a run is due</td>
<td>Every <code>period</code> seconds, plus <code>grace</code></td>
<td>Next time of a five-field cron schedule in UTC, plus <code>gracePeriod</code></td>
</tr>
<tr>
<td>Time zone</td>
<td><code>server_timezone</code> for DST alignment, periods of an hour or more</td>
<td>UTC only</td>
</tr>
<tr>
<td>Signals from a job</td>
<td>Success request; <code>/fail</code>; exit code with output in the body</td>
<td>Heartbeat, or <code>start</code>, <code>complete</code>, and <code>fail</code> with a message of up to 1,000 characters</td>
</tr>
<tr>
<td>Credential</td>
<td>Secret token in the URL</td>
<td>Application API key, sent here in a Basic auth header</td>
</tr>
<tr>
<td>Run duration</td>
<td>Not documented</td>
<td>From <code>start</code> to <code>complete</code></td>
</tr>
<tr>
<td>A hung run</td>
<td>Missed heartbeat at the usual deadline</td>
<td>Stuck at the next expected run plus grace, if it sent <code>start</code></td>
</tr>
<tr>
<td>A job that never runs</td>
<td>Stays Pending</td>
<td><code>pending</code>, then <code>critical</code> with a never-executed alert, if the monitor was created ahead of time</td>
</tr>
<tr>
<td>Result checks</td>
<td>Job output attached to a failure</td>
<td>Up to five numeric or boolean outcomes checked on <code>complete</code></td>
</tr>
<tr>
<td>Notifications</td>
<td>Email, SMS, phone call, push; Slack, Microsoft Teams, webhook, and Zapier integrations, triggered by default or chosen per escalation-policy step</td>
<td>Email, Slack, webhook, Discord, PagerDuty</td>
</tr>
<tr>
<td>On-call and escalation</td>
<td>On-call schedules, <code>team_wait</code>, multi-step escalation policies</td>
<td>None; PagerDuty is the hand-off</td>
</tr>
<tr>
<td>Created from</td>
<td>Dashboard, REST API, Terraform</td>
<td>First check-in with <code>schedule</code>, dashboard, <code>POST /sync</code>, Hangfire and Quartz.NET 3.x discovery</td>
</tr>
<tr>
<td>Who can see it</td>
<td>A responder license per Admin, Team lead, or Responder</td>
<td>Unlimited collaborators</td>
</tr>
<tr>
<td>Price beyond 10</td>
<td>$20 per 10 a month ($17 annually), plus responder licenses</td>
<td>$2, $1, or $0.50 per monitor a month by schedule class</td>
</tr>
</tbody>
</table>
<p>The Better Stack notification row combines the API's alert fields, the <a href="https://betterstack.com/docs/uptime/escalation-policies/">escalation policies</a> page, and the heartbeat section of the <a href="https://betterstack.com/pricing">pricing page</a>.</p>
<h2>When Better Stack Fits Better</h2>
<ul>
<li><strong>Your incidents already run through Better Stack.</strong> A missed backup pages the same on-call schedule, with phone calls, SMS, critical push, and multi-step escalation, none of which CronRadar has.</li>
<li><strong>Jobs run on a local clock that observes daylight saving time.</strong> <code>server_timezone</code> keeps a daily heartbeat aligned; CronRadar needs a UTC schedule and an extra hour of grace.</li>
<li><strong>You want the failing job's output on the incident.</strong> CronRadar's <code>fail</code> message is capped at 1,000 characters, and it deliberately doesn't collect logs.</li>
<li><strong>Alerts must reach Microsoft Teams or Zapier,</strong> which aren't among CronRadar's five channels.</li>
<li><strong>You manage monitoring in Terraform,</strong> with Better Stack's own provider and its heartbeat resource.</li>
<li><strong>Many jobs run more often than every 5 minutes,</strong> you bill annually, and you already pay for responder licenses, so the $1.70 per-heartbeat block rate undercuts CronRadar's $2 Fast rate.</li>
</ul>
<h2>When a Dedicated Cron Monitor Fits Better</h2>
<ul>
<li><strong>The schedule isn't a fixed interval:</strong> weekdays only, uneven times like <code>0 6,20 * * *</code>, or the 1st of the month.</li>
<li><strong>You want more than "it ran":</strong> run duration, stuck runs, and outcome checks on what the run produced.</li>
<li><strong>A job that never runs should alert.</strong> Create the monitor in the dashboard, or let Hangfire discovery create it, and it alerts if the first run never arrives.</li>
<li><strong>Several people need to see job health</strong> without a license each.</li>
<li><strong>Most jobs run hourly or less often,</strong> at $0.50 a month each beyond the free 10.</li>
<li><strong>Your jobs are Hangfire recurring jobs, or Quartz.NET 3.x jobs on repeating simple triggers,</strong> that you want discovered instead of wired one at a time.</li>
</ul>
<p>To move a heartbeat over, take the schedule from the crontab or scheduler rather than from the period, write it in UTC, and run both monitors for one normal schedule window. Confirm a real run reached CronRadar and a test alert reached its recipient before you delete the heartbeat (<a href="https://docs.cronradar.com/migrations">migration guide</a>). For more options, see <a href="https://cronradar.com/comparisons/best-cron-monitoring-tools">cron monitoring tools compared</a>.</p>
<h2>Frequently Asked Questions</h2>
<h3>Do Better Stack heartbeats support cron expressions?</h3>
<p>No. A heartbeat takes a <code>period</code> and a <code>grace</code>, both in seconds, and the create-heartbeat API has no schedule field. For periods of an hour or more, <code>server_timezone</code> keeps the period aligned with wall-clock time across daylight saving changes, but a weekday-only or monthly schedule still has to be approximated with a period.</p>
<h3>How do I report a failed job to a Better Stack heartbeat?</h3>
<p>Request the heartbeat URL with <code>/fail</code> appended, or append the job's exit code and send its output as the request body, which Better Stack says helps diagnose the failure. Send the plain URL only after a successful run, and keep the wrapper's own exit code equal to the job's.</p>
<h3>Is the Better Stack heartbeat URL a secret?</h3>
<p>Yes. The URL carries a token, and Better Stack's guide calls it a secret URL; the documented requests use no other credential. Anyone with it can report the job as healthy or failed, so keep it in a file only the job's account can read or in a secret store, not in a crontab or repository.</p>
<h3>How much do Better Stack heartbeats cost?</h3>
<p>As of October 30, 2026, the free personal option lists "10 monitors &#x26; heartbeats." On paid plans, 10 heartbeats are included and each additional 10 cost $20 a month, or $17 billed annually. Each Admin, Team lead, or Responder needs a responder license at $34 a month, or $29 billed annually.</p>
<h3>What happens if a Better Stack heartbeat never gets its first request?</h3>
<p>It stays Pending. Better Stack starts the monitoring period at the first heartbeat, so by its guide's description a job that was never deployed doesn't produce an incident. Send one request when you install the job, and check that the heartbeat has left Pending.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>better stack heartbeat</category>
      <category>better stack cron monitoring</category>
      <category>better stack heartbeat pricing</category>
      <category>better stack heartbeat fail url</category>
      <category>better stack heartbeat alternative</category>
    </item>
    <item>
      <title><![CDATA[Celery Task Retries: self.retry, autoretry_for, and Final-Failure Alerts]]></title>
      <link>https://cronradar.com/blog/celery-task-retries</link>
      <guid>https://cronradar.com/blog/celery-task-retries</guid>
      <description><![CDATA[By default, Celery task retry() allows 3 retries, 180 seconds apart. How autoretry_for, backoff, jitter, and acks_late work, and how to alert on final failure.]]></description>
      <content:encoded><![CDATA[<p><strong>Celery retries a task only when the task asks for it: <code>raise self.retry(exc=exc)</code> in an <code>except</code> block, or <code>autoretry_for=(SomeError,)</code> on the decorator. Both allow 3 retries, so 4 runs, wait 180 seconds between them unless you pass a <code>countdown</code> or set <code>retry_backoff</code>, and then re-raise the original exception, which is the point where <code>task_failure</code> fires; a retry never fires it.</strong> A worker process that dies mid-task takes a separate path, controlled by <code>acks_late</code> and <code>task_reject_on_worker_lost</code>. By default that path doesn't run the task again; when either path does rerun it, the task starts from its first line.</p>
<p>This guide covers Celery 5.6.3, the current release on <a href="https://pypi.org/project/celery/5.6.3/">PyPI</a> (checked October 30, 2026); source links point to the v5.6.3 tag. Which failures deserve a retry, and how other schedulers retry, is in <a href="https://cronradar.com/blog/cron-job-retries">how to retry failed cron jobs</a>. Flower, Prometheus, and worker health are in <a href="https://cronradar.com/blog/celery-monitoring">Celery task monitoring</a>.</p>
<h2>How self.retry() Works</h2>
<pre><code class="language-python"># billing/tasks.py
from celery import shared_task

from billing.ledger import LedgerUnavailable, push_ledger_batch


@shared_task(bind=True, max_retries=5, default_retry_delay=120)
def push_ledger(self, batch_id):
    try:
        push_ledger_batch(batch_id)
    except LedgerUnavailable as exc:
        # Overrides default_retry_delay for this attempt only.
        raise self.retry(exc=exc, countdown=60)
</code></pre>
<p><code>retry()</code> publishes a new message with the same task ID to the same queue, then raises <code>Retry</code>, which the worker treats as a state change rather than an error (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#retrying">retrying</a>). The <code>raise</code> in front is for readability, since <code>retry()</code> raises either way. Decorator options become attributes of the task class (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/base.py#L608-L626">base.py</a>), so <code>max_retries=5</code> above is the same as setting it on a <code>Task</code> subclass.</p>
<p>The defaults live on <code>Task</code> (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L216-L222">task.py</a>) and in the autoretry wrapper (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/autoretry.py#L10-L31">autoretry.py</a>):</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>Default</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>max_retries</code></td>
<td><code>3</code></td>
<td>Retries after the first run; <code>None</code> retries forever</td>
</tr>
<tr>
<td><code>default_retry_delay</code></td>
<td><code>180</code></td>
<td>Seconds before a retry when no <code>countdown</code> or <code>eta</code> is passed</td>
</tr>
<tr>
<td><code>autoretry_for</code></td>
<td><code>()</code></td>
<td>No exception is retried automatically</td>
</tr>
<tr>
<td><code>dont_autoretry_for</code></td>
<td><code>()</code></td>
<td>Nothing is excluded</td>
</tr>
<tr>
<td><code>retry_kwargs</code></td>
<td><code>{}</code></td>
<td>Extra arguments for the automatic <code>retry()</code> call</td>
</tr>
<tr>
<td><code>retry_backoff</code></td>
<td><code>False</code></td>
<td>No exponential delay</td>
</tr>
<tr>
<td><code>retry_backoff_max</code></td>
<td><code>600</code></td>
<td>Ceiling in seconds when backoff is on</td>
</tr>
<tr>
<td><code>retry_jitter</code></td>
<td><code>True</code></td>
<td>Randomizes each backoff delay</td>
</tr>
</tbody>
</table>
<h3>When Celery Gives Up</h3>
<p><code>self.request.retries</code> is 0 on the first run and goes up by one on each retry (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#task-request-info">request attributes</a>). <code>retry()</code> gives up when <code>request.retries + 1</code> exceeds <code>max_retries</code> (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L742-L773">task.py</a>), so the default allows 4 runs, and <code>max_retries=5</code> allows 6. What it raises then depends on <code>exc</code>:</p>
<ul>
<li><strong>With <code>exc</code></strong>, it re-raises that exception, so the result backend, logs, and <code>task_failure</code> see <code>LedgerUnavailable</code>, not a Celery wrapper.</li>
<li><strong>Without <code>exc</code></strong>, it raises <code>MaxRetriesExceededError</code>, whose message is <code>Can't retry &#x3C;task>[&#x3C;id>] args:&#x3C;args> kwargs:&#x3C;kwargs></code>. That puts the task's arguments into every place the exception goes, so pass <code>exc</code>.</li>
</ul>
<p>Three more rules from the same method:</p>
<ul>
<li><strong><code>retry(max_retries=...)</code> applies to that call only.</strong> It doesn't carry over to later attempts, and passing <code>None</code> means "use the default", so the only way to retry forever is <code>max_retries=None</code> on the task itself.</li>
<li><strong>Called as a plain function, <code>retry()</code> raises at once.</strong> It raises the <code>exc</code> you passed, or <code>Retry</code> if you passed none, instead of scheduling anything (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L748-L753">task.py</a>). Under <code>apply()</code> or <code>task_always_eager</code>, the retry runs immediately in the same process (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L868-L869">task.py</a>).</li>
<li><strong>If the retry can't be published, the retry is lost without a failure.</strong> When publishing the new message fails, for example because the broker is unreachable, <code>retry()</code> raises <code>Reject(requeue=False)</code> (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L785-L788">task.py</a>). The worker logs a warning, doesn't requeue the current message (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/worker/request.py#L603-L604">request.py</a>), and fires neither <code>task_retry</code> nor <code>task_failure</code>.</li>
</ul>
<h2>autoretry_for and Exponential Backoff</h2>
<p><code>autoretry_for</code> wraps the task's <code>run</code> method. When the method raises a listed exception, the wrapper calls <code>task.retry(exc=exc, **retry_kwargs)</code> for you; <code>Retry</code>, <code>Ignore</code>, and anything in <code>dont_autoretry_for</code> pass through untouched (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/autoretry.py#L33-L64">autoretry.py</a>). An exception that isn't listed fails on the first attempt.</p>
<pre><code class="language-python"># billing/tasks.py
import os

import requests
from celery import shared_task
from requests.exceptions import ConnectionError as RequestsConnectionError
from requests.exceptions import Timeout


@shared_task(
    autoretry_for=(RequestsConnectionError, Timeout),
    max_retries=5,
    retry_backoff=60,       # Delay ceilings of 60, 120, 240, 480, then 600 seconds
    retry_backoff_max=600,  # The default, shown for clarity
)
def sync_supplier_prices():
    response = requests.post(os.environ["SUPPLIER_SYNC_URL"], timeout=30)
    # A 4xx or 5xx raises HTTPError, which isn't listed: it fails on the first attempt.
    response.raise_for_status()
    return response.status_code
</code></pre>
<p><code>HTTPError</code>, <code>ConnectionError</code>, and <code>Timeout</code> are separate subclasses of <code>RequestException</code> in requests 2.34.2 (<a href="https://github.com/psf/requests/blob/v2.34.2/src/requests/exceptions.py#L66-L82">exceptions.py</a>), and <code>raise_for_status()</code> raises <code>HTTPError</code> for any 4xx or 5xx status (<a href="https://github.com/psf/requests/blob/v2.34.2/src/requests/models.py#L1140-L1167">models.py</a>). Listing <code>RequestException</code> would retry all three, 4xx responses included. To retry a family except some members, list both: <code>autoretry_for=(OSError,)</code> with <code>dont_autoretry_for=(FileNotFoundError,)</code> retries every <code>OSError</code> except a missing file, which Python defines as an <code>OSError</code> subclass (<a href="https://docs.python.org/3.13/library/exceptions.html#exception-hierarchy">exception hierarchy</a>).</p>
<h3>What the Backoff Computes</h3>
<p>With <code>retry_backoff</code> on, the wrapper sets the countdown from <a href="https://github.com/celery/celery/blob/v5.6.3/celery/utils/time.py#L448-L462"><code>get_exponential_backoff_interval</code></a>:</p>
<pre><code class="language-text">ceiling(n) = min(retry_backoff_max, factor * 2^n)   where n = request.retries (0 on the first failure)
delay(n)   = random integer from 0 to ceiling(n)    if retry_jitter (the default)
           = ceiling(n)                             otherwise
</code></pre>
<p><code>retry_backoff=True</code> makes the factor 1, and a number is used as the factor in seconds, rounded down, with a minimum of 1. Jitter doesn't add noise around the ceiling: it picks a whole number of seconds anywhere from zero up to it, so the average wait is about half of it (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#automatic-retry-for-known-exceptions">retry_jitter</a>).</p>
<table>
<thead>
<tr>
<th>Options</th>
<th>Wait before each retry</th>
<th>Runs</th>
<th>Retry waits add up to</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>self.retry(exc=exc)</code> or <code>autoretry_for</code></td>
<td>180 s each</td>
<td>4</td>
<td>9 min</td>
</tr>
<tr>
<td><code>retry_backoff=True</code></td>
<td>0–1 s, 0–2 s, 0–4 s</td>
<td>4</td>
<td>at most 7 s</td>
</tr>
<tr>
<td><code>retry_backoff=True, retry_jitter=False</code></td>
<td>1 s, 2 s, 4 s</td>
<td>4</td>
<td>7 s</td>
</tr>
<tr>
<td><code>retry_backoff=60, max_retries=5</code></td>
<td>0–60, 0–120, 0–240, 0–480, 0–600 s</td>
<td>6</td>
<td>at most 25 min</td>
</tr>
<tr>
<td><code>max_retries=None</code></td>
<td>As configured</td>
<td>Unlimited</td>
<td>Never gives up</td>
</tr>
</tbody>
</table>
<p>Two rows deserve a second look. <code>retry_backoff=True</code> at the default <code>max_retries</code> finishes all its retries within seven seconds, too soon for most outages to clear, so pass a factor in seconds instead. And the first row disagrees with the documentation: the <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#automatic-retry-for-known-exceptions">tasks guide</a> says that with <code>retry_backoff</code> off "autoretries will not be delayed", but the 5.6.3 wrapper then passes no countdown, and <code>retry()</code> <a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L755-L756">falls back to <code>default_retry_delay</code></a>, so automatic retries wait three minutes each. Set <code>retry_kwargs={"countdown": 10}</code> for a different fixed delay.</p>
<p>Every delay is a minimum. The retry message carries an ETA, the earliest time it may run, and Celery only guarantees it runs "at some time <em>after</em> the specified date and time" (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/calling.html#eta-and-countdown">ETA and countdown</a>), when a worker is free.</p>
<h2>acks_late and Worker Loss Are a Different Path</h2>
<p><code>retry()</code> only handles exceptions the task raises. Celery's FAQ is blunt about the other case: retries are "for expected errors that is catch-able with the try block", and "if the task raises an exception it's still acknowledged!", while <code>acks_late</code> is for running the task again "if the worker (for some reason) crashes mid-execution" (<a href="https://docs.celeryq.dev/en/v5.6.3/faq.html#should-i-use-retry-or-acks-late">retry or acks_late</a>). The three acknowledgement settings and their defaults are in <a href="https://cronradar.com/blog/celery-monitoring">Celery task monitoring</a>; this is how they interact with retries, from the worker's <a href="https://github.com/celery/celery/blob/v5.6.3/celery/worker/request.py#L576-L658"><code>on_failure</code></a>:</p>
<table>
<thead>
<tr>
<th>What happened</th>
<th>Default (ack before run)</th>
<th><code>acks_late</code></th>
<th><code>acks_late</code> + <code>task_reject_on_worker_lost</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Task raised a retried exception, limit not reached</td>
<td>Retried by <code>retry()</code></td>
<td>Same</td>
<td>Same</td>
</tr>
<tr>
<td>Task raised; retry limit reached or not retried</td>
<td><code>FAILURE</code>, <code>task_failure</code></td>
<td>Same</td>
<td>Same</td>
</tr>
<tr>
<td>Pool process died (<code>WorkerLostError</code>), e.g. OOM</td>
<td><code>FAILURE</code>, <code>task_failure</code>, no rerun</td>
<td>Same</td>
<td>Requeued and run again, no <code>task_failure</code></td>
</tr>
<tr>
<td>Whole worker killed with <code>KILL</code> mid-task</td>
<td>Lost: it was acknowledged before it ran</td>
<td>Broker redelivers</td>
<td>Broker redelivers</td>
</tr>
</tbody>
</table>
<p>The lost-task row is from the <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/workers.html#stopping-the-worker">workers guide</a>; under <code>acks_late</code> the message hasn't been acknowledged yet, so it's <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html">redelivered to another worker</a>. The last column is the only automatic rerun after a pool process dies, and it isn't counted as a retry: the broker delivers the same message again, and nothing in that path changes <code>request.retries</code>, so <code>max_retries</code> doesn't limit it. A task that reliably kills its own process gets requeued every time, the message loop the <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#task-reject-on-worker-lost">configuration reference</a> warns about.</p>
<p>Long retry delays interact with redelivery too. The <a href="https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/redis.html#redis-caveats">Redis caveats</a> warn that "ETA/countdown/retry tasks where the time to execute exceeds the visibility timeout" are "executed again, and again in a loop". The visibility timeout is one hour by default on Redis and 30 minutes on <a href="https://docs.celeryq.dev/en/v5.6.3/getting-started/backends-and-brokers/sqs.html#sqs-visibility-timeout">SQS</a>, so keep each retry delay well under it; <code>retry_backoff_max</code> at its default of 600 seconds does.</p>
<h2>Make Each Attempt Safe to Run Again</h2>
<p>A retry and a redelivery both run the task from its first line; <a href="https://cronradar.com/blog/idempotent-scheduled-jobs#give-every-external-side-effect-a-key">idempotent cron jobs</a> covers keying each side effect so a second run doesn't repeat it. The task ID stays the same for every retry of one run, because <code>retry()</code> <a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L145-L166">copies it into the new message</a>, and a redelivery is the same message. That makes <code>self.request.id</code> a usable idempotency key for side effects within one run, such as the key on a payment API call.</p>
<p>It doesn't protect against two separate sends of the same work, such as two beats each sending the 06:00 run: those are two task IDs. For that, key on the period the run covers, as <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">idempotent cron jobs</a> shows with a runs table. Celery's own <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#worker-deduplicate-successful-tasks"><code>worker_deduplicate_successful_tasks</code></a> (default off) covers one narrow case: it skips a redelivered late-acknowledged message whose task ID is already <code>SUCCESS</code> in a persistent result backend. A run that died halfway isn't <code>SUCCESS</code>, so it runs again in full.</p>
<h2>Retries for Beat-Scheduled Tasks</h2>
<p>Each time an entry is due, beat calls <code>apply_async</code> for it (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/beat.py#L393-L414">beat.py</a>) without looking at earlier runs. Every occurrence is a new task with its own ID and its own retry budget, and the 03:00 run of an hourly task is sent on time even if the 02:00 run is still waiting for its fourth attempt. Three consequences:</p>
<ul>
<li><strong>A long chain overlaps the next run.</strong> With five retries backing off to 600 seconds, a <code>*/15</code> task can have one occurrence's retry running beside, or after, the next occurrence. If the job writes "latest" values, an old retry that finishes last overwrites newer data. Keep the whole chain, retry waits plus run times, shorter than the interval, or prevent overlap with a lock as in <a href="https://cronradar.com/blog/prevent-overlapping-cron-jobs">preventing overlapping cron jobs</a>.</li>
<li><strong>A retry inherits the entry's <code>expires</code>.</strong> A beat entry can set <code>"options": {"expires": ...}</code> so a backlog doesn't run stale copies. The retry message keeps the original expiry time, which by then is an absolute timestamp (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/amqp.py#L352-L354">amqp.py</a>), and a worker marks an expired task <code>REVOKED</code> instead of running it (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/calling.html#expiration">expiration</a>), checking again when the task is about to execute (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/worker/request.py#L350-L353">request.py</a>). A retry that comes due after the expiry never runs, and the chain ends in <code>task_revoked</code>, not <code>task_failure</code>.</li>
<li><strong>A retry runs late, so "now" is the wrong clock.</strong> A nightly job that exports "yesterday" from <code>datetime.now()</code> exports the wrong day if a retry runs after midnight. Fix the period on the first attempt and pass it on, since <code>retry()</code> accepts replacement <code>args</code> and <code>kwargs</code> for the next message (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L680-L725">task.py</a>):</li>
</ul>
<pre><code class="language-python"># reports/tasks.py
from datetime import datetime, timedelta, timezone

from celery import shared_task

from reports.export import ExportTargetUnavailable, upload_daily_export


@shared_task(bind=True, max_retries=4, default_retry_delay=300)
def export_daily_orders(self, day=None):
    # The first attempt picks the day; every retry receives it as an argument.
    if day is None:
        day = (datetime.now(timezone.utc).date() - timedelta(days=1)).isoformat()
    try:
        upload_daily_export(day)
    except ExportTargetUnavailable as exc:
        raise self.retry(exc=exc, kwargs={"day": day})
</code></pre>
<p><code>kwargs</code> replaces the original keyword arguments rather than merging with them (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L655-L660">task.py</a>), so pass all of them. A first attempt that itself starts late, after a backlog, still computes from its own start time; <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">idempotent cron jobs</a> covers naming the period by the work instead of the clock.</p>
<h2>Which Hooks Fire on a Retry and Which on the Final Failure</h2>
<p>From the tracer (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/trace.py#L212-L275">trace.py</a>) and the worker's request handling (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/worker/request.py#L567-L658">request.py</a>):</p>
<table>
<thead>
<tr>
<th>What happened</th>
<th>Signals</th>
<th>Task methods</th>
</tr>
</thead>
<tbody>
<tr>
<td>An attempt starts, retries included</td>
<td><code>task_prerun</code></td>
<td><code>before_start</code></td>
</tr>
<tr>
<td>The task raised and will be retried</td>
<td><code>task_retry</code>, then <code>task_postrun</code> with <code>RETRY</code></td>
<td><code>on_retry</code></td>
</tr>
<tr>
<td>The task raised and won't be: limit reached, or not listed</td>
<td><code>task_failure</code>, then <code>task_postrun</code> with <code>FAILURE</code></td>
<td><code>on_failure</code>, <code>after_return</code></td>
</tr>
<tr>
<td>The task returned</td>
<td><code>task_success</code>, then <code>task_postrun</code> with <code>SUCCESS</code></td>
<td><code>on_success</code>, <code>after_return</code></td>
</tr>
<tr>
<td>The pool process died and the message wasn't requeued</td>
<td><code>task_failure</code>, from the worker's main process</td>
<td>None</td>
</tr>
<tr>
<td>A retry came due after the entry's <code>expires</code></td>
<td><code>task_revoked</code> with <code>expired=True</code></td>
<td>None</td>
</tr>
<tr>
<td><code>retry()</code> couldn't publish the retry message</td>
<td><code>task_postrun</code> with <code>REJECTED</code></td>
<td>None</td>
</tr>
</tbody>
</table>
<p>So <code>task_failure</code> is the final-failure hook: it fires once per run when Celery stops retrying, never for a retry, and it's the only signal or task method that also covers a dead pool process. A <code>Task.on_failure</code> method on a base class misses that case, because it runs in the process that died; Celery's docs point to the worker-side <code>Request.on_failure</code>, which runs in the main process, as a way to detect failures that <code>Task.on_failure</code> doesn't (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#requests-and-custom-requests">custom requests</a>). <code>after_return</code> is skipped for <code>RETRY</code> (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#handlers">handlers</a>), so it runs only at the end of a run, though not for a dead pool process.</p>
<p>For per-attempt visibility, use <code>task_retry</code> or <code>task_postrun</code>. <code>task_retry</code> also fires, without <code>reason</code> or <code>einfo</code>, when a running task is cancelled and retried, so a handler must not assume those arguments exist (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/signals.html#task-retry">task_retry</a>).</p>
<h2>Report Only the Final Failure to CronRadar</h2>
<p>Celery's hooks run only while workers run and beat sends. A schedule-based check from outside covers the rest: no <code>start</code> by the expected time means the run never happened, whichever part failed. CronRadar's Celery SDK is unavailable (checked September 20, 2026; the <a href="https://docs.cronradar.com/integrations/celery">Celery integration docs</a> carry the current notice), so report with HTTP check-ins. HTTP check-ins don't discover beat schedules: each task needs its own monitor key and its real schedule.</p>
<p>The signal reporter in <a href="https://cronradar.com/blog/celery-monitoring#reporting-celery-tasks-to-cronradar-over-http">Celery task monitoring</a> already reports a <code>start</code> for every attempt, nothing for a retry, and a <code>fail</code> only when Celery gives up. This version changes three things for retries:</p>
<ul>
<li><strong>The <code>fail</code> handler takes <code>task_id</code> from the <code>task_failure</code> signal.</strong> When a pool process dies, the worker's main process sends <code>task_failure</code> (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/worker/request.py#L576-L658">request.py</a>), and that process never pushed this run's request, so <code>sender.request</code> is an empty default context (<a href="https://github.com/celery/celery/blob/v5.6.3/celery/app/task.py#L1164-L1174">task.py</a>) and <code>sender.request.id</code> can't be the <code>run_id</code>.</li>
<li><strong>A <code>task_revoked</code> handler reports a retry that expired</strong>, which ends the chain without <code>task_failure</code>.</li>
<li><strong>Each mapping carries a grace period</strong> next to the schedule.</li>
</ul>
<p>The request code is the standard-library helper from <a href="https://cronradar.com/blog/celery-beat-not-running#report-each-run-from-inside-the-task">Celery beat not running</a>, with an <code>attempt</code> parameter added: HTTP Basic auth with the key from <code>CRONRADAR_API_KEY</code>, and errors logged instead of raised.</p>
<pre><code class="language-python"># proj/monitoring.py
import base64
import logging
import os
import urllib.parse
import urllib.request

from celery.signals import task_failure, task_prerun, task_revoked, task_success

log = logging.getLogger(__name__)

# Celery task name -> (monitor key, the beat entry's schedule as five-field cron in UTC,
# grace period in seconds). Schedule and grace apply when a check-in creates the monitor.
MONITORED_TASKS = {
    "reports.tasks.export_daily_orders": ("export-daily-orders", "30 2 * * *", 900),
    "billing.tasks.sync_supplier_prices": ("sync-supplier-prices", "*/15 * * * *", 300),
}


def check_in(task_name, event, *, run_id, attempt=None, message=None):
    """Report one lifecycle event to CronRadar. Never raises into Celery."""
    monitor = MONITORED_TASKS.get(task_name)
    if monitor is None:
        return
    key, schedule, grace_period = monitor
    params = {
        "schedule": schedule,
        "gracePeriod": grace_period,
        "run_id": run_id,
        "attempt": attempt,
        "message": message,
    }
    query = urllib.parse.urlencode(
        {name: value for name, value in params.items() if value is not None},
        quote_via=urllib.parse.quote,
    )
    url = f"https://cron.life/api/v1/ping/{urllib.parse.quote(key, safe='')}/{event}?{query}"
    try:
        token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
        request = urllib.request.Request(url, data=b"", method="POST")
        # Unlike add_header(), this header is not copied to a redirect.
        request.add_unredirected_header("Authorization", f"Basic {token}")
        with urllib.request.urlopen(request, timeout=5):
            pass
    except Exception as error:
        # A monitoring problem must never fail the task or change its result.
        log.warning("CronRadar %s for %s not recorded (%s)", event, key, type(error).__name__)


@task_prerun.connect
def on_attempt_start(sender=None, task_id=None, task=None, **kwargs):
    # Every attempt shares the task ID; request.retries is 0 on the first.
    check_in(sender.name, "start", run_id=task_id, attempt=task.request.retries)


@task_success.connect
def on_success(sender=None, **kwargs):
    check_in(sender.name, "complete", run_id=sender.request.id, attempt=sender.request.retries)


@task_failure.connect
def on_final_failure(sender=None, task_id=None, exception=None, **kwargs):
    # Never sent for a retry. For a dead pool process it comes from the worker's
    # main process, where sender.request isn't this run, so use task_id.
    # The exception type only: never arguments, payloads, or log output.
    check_in(sender.name, "fail", run_id=task_id, message=type(exception).__name__)


@task_revoked.connect
def on_revoked(sender=None, request=None, expired=False, **kwargs):
    # A retry that outlived the entry's expires ends here, not in task_failure.
    if expired:
        check_in(sender.name, "fail", run_id=request.id, message="Expired")
</code></pre>
<p>Load it with <code>app.conf.imports = ["proj.monitoring"]</code>, which imports the module <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/configuration.html#imports">when the worker starts</a>. With the prefork pool, the <code>task_failure</code> call for a dead pool process and the <code>task_revoked</code> call run in the worker's main process (<a href="https://docs.celeryq.dev/en/v5.6.3/userguide/tasks.html#requests-and-custom-requests">custom requests</a>, <a href="https://docs.celeryq.dev/en/v5.6.3/userguide/signals.html#task-revoked">task_revoked</a>), so a slow check-in there blocks the main process rather than one pool process. <code>timeout=5</code> limits each blocking operation, such as the connection attempt, not the check-in as a whole (<a href="https://docs.python.org/3.13/library/urllib.request.html#urllib.request.urlopen"><code>urlopen</code></a>).</p>
<p>What CronRadar records:</p>
<ul>
<li><strong>Each attempt is part of one run.</strong> The task ID is the <code>run_id</code> and <code>request.retries</code> is the zero-based <code>attempt</code> (<a href="https://docs.cronradar.com/api-reference">HTTP API reference</a>). CronRadar groups the events that share a <code>run_id</code> into one run and counts its attempts from the distinct <code>attempt</code> values on its starts. It never retries your work itself.</li>
<li><strong>A retry opens no incident.</strong> The failed attempt sends no <code>fail</code>, and the next attempt's <code>start</code> updates the same run.</li>
<li><strong>The final failure alerts immediately.</strong> <code>fail</code> marks the monitor <code>critical</code> with the result <code>failed</code> and opens an incident, alerting through email, Slack, webhook, Discord, or PagerDuty. The alert arrives when the chain ends: for <code>export_daily_orders</code>, at least 20 minutes after the first error (four 300-second waits) plus run times. A later successful run records a recovery.</li>
<li><strong>Grace covers the first attempt only.</strong> Every check-in, each attempt's <code>start</code> included, moves the next expected time to the schedule's next occurrence, so the grace period has to cover how late the first attempt can start, not the retry chain. How a missed run moves from <code>warning</code> to <code>critical</code> is in <a href="https://cronradar.com/blog/celery-beat-not-running#report-each-run-from-inside-the-task">Celery beat not running</a>.</li>
<li><strong>A run that ends without a hook stays open.</strong> A lost retry message or a killed worker leaves a <code>start</code> with no result, flagged as stuck if no check-in arrives by the next expected time plus grace. If the workers are otherwise fine, the next occurrence's <code>start</code> resets that window, and the lost run stays in the run history labeled running, with no result.</li>
<li><strong>A chain longer than the interval can alert late.</strong> If an old run's final <code>fail</code> arrives after a newer run succeeded, it still opens an incident, for the older run. Short chains avoid that.</li>
</ul>
<p>Each mapping's <code>schedule</code> is its beat entry written as five-field cron in UTC (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>); <a href="https://cronradar.com/blog/celery-beat-not-running#report-each-run-from-inside-the-task">Celery beat not running</a> has a table translating <code>crontab()</code> entries, including ones in other time zones.</p>
<h2>Frequently Asked Questions</h2>
<h3>How many times does Celery retry a task by default?</h3>
<p>Zero, unless the task calls <code>self.retry()</code> or lists exceptions in <code>autoretry_for</code>. When it does, <code>max_retries</code> defaults to 3, so a task that keeps failing runs 4 times. <code>max_retries=None</code> retries forever.</p>
<h3>What is the default delay between Celery retries?</h3>
<p>180 seconds, from <code>default_retry_delay</code>, for both <code>self.retry()</code> without a <code>countdown</code> and <code>autoretry_for</code> without <code>retry_backoff</code>. With <code>retry_backoff=True</code>, the ceilings are 1, 2, and 4 seconds, and the default jitter picks a random delay from zero up to each one.</p>
<h3>When does Celery raise MaxRetriesExceededError?</h3>
<p>When the retry limit is reached and <code>retry()</code> was called without <code>exc</code>. With <code>exc</code>, Celery re-raises that exception instead, which is what <code>autoretry_for</code> always does. The <code>MaxRetriesExceededError</code> message includes the task's arguments, so pass <code>exc</code> to keep them out of logs and alerts.</p>
<h3>Does task_failure fire on every retry?</h3>
<p>No. A retry fires <code>task_retry</code> and <code>on_retry</code>. <code>task_failure</code> fires once, when Celery stops retrying or the exception isn't one it retries, and also when the pool process running the task dies and the message isn't requeued. It doesn't fire for a retry that expired, which sends <code>task_revoked</code>.</p>
<h3>Does acks_late retry a task that raised an exception?</h3>
<p>No. With <code>acks_late</code>, a task that raises is still acknowledged by default, and only <code>retry()</code> or <code>autoretry_for</code> runs it again. <code>acks_late</code> matters when the worker dies mid-task: with <code>task_reject_on_worker_lost</code> as well, a dead pool process puts the message back in the queue, without counting a retry.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>celery task retry</category>
      <category>celery autoretry_for</category>
      <category>celery retry_backoff</category>
      <category>celery MaxRetriesExceededError</category>
      <category>celery task_failure signal</category>
    </item>
    <item>
      <title><![CDATA[Crontab Errors: "bad minute", "premature EOF", and Other Messages]]></title>
      <link>https://cronradar.com/blog/crontab-errors</link>
      <guid>https://cronradar.com/blog/crontab-errors</guid>
      <description><![CDATA[What crontab's "bad minute", "bad command", "premature EOF", and "errors in crontab file, can't install" mean in cronie, Debian, and BusyBox, and each fix.]]></description>
      <content:encoded><![CDATA[<p><strong><code>"/tmp/crontab.Xq3fLa/crontab":4: bad minute</code> followed by <code>errors in crontab file, can't install.</code> means <code>crontab</code> couldn't parse the first field of a line in the file you saved, so it refused the whole file and left your installed crontab unchanged.</strong> Debian and Ubuntu print that second line; cronie, the cron on Fedora, RHEL, and Arch, prints <code>Invalid crontab file, can't install.</code> instead. BusyBox's <code>crontab</code>, on Alpine, installs anything, and its errors appear later in crond's log as <code>user root: parse error at ...</code>.</p>
<p>Below, each message is traced to the source line that prints it, with what triggers it and the fix. This guide covers cronie 1.7.2, the current release on <a href="https://github.com/cronie-crond/cronie/releases/tag/cronie-1.7.2">GitHub</a>; Debian's cron 3.0pl1-197, the version in Debian 13 (<a href="https://tracker.debian.org/pkg/cron">tracker.debian.org</a>); and BusyBox 1.37.0, the version in Alpine 3.24 (<a href="https://gitlab.alpinelinux.org/alpine/aports/-/blob/3.24-stable/main/busybox/APKBUILD">APKBUILD</a>) (checked October 30, 2026). Debian testing's <a href="https://salsa.debian.org/debian/cron/-/tree/debian/3.0pl1-212">cron 3.0pl1-212</a> prints the same messages plus a <code>pathname too long</code> check on the file argument (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-212/debian/patches/fixes/crontab-raises-an-error-for-too-long-CLI.patch#L24-29">patch</a>), and BusyBox 1.38.0's <a href="https://github.com/vda-linux/busybox_mirror/blob/1_38_0/miscutils/crond.c">crond.c</a> prints the same messages as 1.37.0's. Ubuntu 24.04's cron 3.0pl1-184ubuntu2 prints all of Debian's except <code>unsafe mail</code> (<a href="https://git.launchpad.net/ubuntu/+source/cron/tree/crontab.c?h=applied/ubuntu/noble">Launchpad</a>). RHEL 9's cronie 1.5.7 carries Red Hat backports of the step-size warning, the <code>-n</code> job option, and the 10,000-entry limit (<a href="https://gitlab.com/redhat/centos-stream/rpms/cronie/-/blob/e7a03758c1ec4958210449dec7d1d2916948ad26/cronie.spec#L16-47">CentOS Stream 9 cronie.spec</a>), so it prints the same messages as cronie 1.7.2. Editing crontabs and checking one without installing it are covered in the <a href="https://cronradar.com/blog/crontab-guide">crontab guide</a>; to find out which cron you run, see <a href="https://cronradar.com/blog/cron-implementations">cron implementations</a>.</p>
<h2>Which Cron Printed It</h2>
<table>
<thead>
<tr>
<th>Message</th>
<th>Printed by</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"FILE":N: bad minute</code>, <code>bad hour</code>, ... <code>bad command</code></td>
<td>cronie and Debian <code>crontab</code></td>
<td>A line fails to parse</td>
</tr>
<tr>
<td><code>Invalid crontab file, can't install.</code></td>
<td>cronie <code>crontab</code></td>
<td>After any parse error</td>
</tr>
<tr>
<td><code>errors in crontab file, can't install.</code></td>
<td>Debian and Ubuntu <code>crontab</code></td>
<td>After any parse error</td>
</tr>
<tr>
<td><code>"FILE":N: premature EOF</code></td>
<td>cronie <code>crontab</code></td>
<td>The last line has no newline</td>
</tr>
<tr>
<td><code>new crontab file is missing newline before EOF, can't install.</code></td>
<td>Debian and Ubuntu <code>crontab</code></td>
<td>The last line has no newline</td>
</tr>
<tr>
<td><code>Do you want to retry the same edit? (Y/N)</code></td>
<td>cronie <code>crontab -e</code></td>
<td>After a refused edit</td>
</tr>
<tr>
<td><code>Do you want to retry the same edit? (y/n)</code></td>
<td>Debian <code>crontab -e</code></td>
<td>After a refused edit</td>
</tr>
<tr>
<td><code>(CRON) bad minute (/etc/cron.d/app)</code></td>
<td>cronie <code>crond</code></td>
<td>Loading a file with a bad line, which it skips</td>
</tr>
<tr>
<td><code>Error: bad minute; while reading /etc/cron.d/app</code></td>
<td>Debian <code>cron</code></td>
<td>Loading a file with a bad line, then ignoring that file</td>
</tr>
<tr>
<td><code>user root: parse error at 60</code></td>
<td>BusyBox <code>crond</code></td>
<td>Loading a crontab with a field it can't read</td>
</tr>
</tbody>
</table>
<p>Sources: cronie <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L51-L63">entry.c</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L874-L897">crontab.c</a>, and <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c#L119-L164">user.c</a>; Debian <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L42-52">entry.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L555">crontab.c</a>, and the patches cited below; BusyBox <a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L321-L324">crond.c</a>.</p>
<h2>How crontab Reports a Bad Line</h2>
<p>cronie and Debian check the file when you save from <code>crontab -e</code>, run <code>crontab file</code>, or pipe into <code>crontab -</code>. On the first line that fails, both print the file name, a line number, and the reason with the same format string, <code>"%s":%d: %s</code> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L611-L614">cronie crontab.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L289">Debian crontab.c</a>), then the summary line:</p>
<pre><code class="language-text">"/tmp/crontab.Xq3fLa":4: bad minute
Invalid crontab file, can't install.
</code></pre>
<ul>
<li><strong>The file name</strong> is crontab's temporary copy when you used <code>crontab -e</code> (by default <code>/tmp/crontab.XXXXXX</code> on cronie and <code>/tmp/crontab.XXXXXX/crontab</code> on Debian), the path you passed, or <code>-</code> for standard input (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L683-L684">cronie</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L366-L369">L366-L369</a>; <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Allow-editors-with-tmpfiles.patch#L51-84">Debian</a>).</li>
<li><strong>Only the first error is reported.</strong> Both stop checking at the first bad line (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L1074">cronie</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L534-539">Debian</a>). Fix it and check again to find the next one.</li>
<li><strong>Debian's line number is usually one too low.</strong> crontab prints its line count minus one (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L289">crontab.c</a>), which is right only once the bad line's newline has been read. Most errors, such as an out-of-range value, a bad name, or a <code>*</code> where the command should start, are found earlier in the line and printed before crontab reads its newline (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L296-301">entry.c</a>), so they're reported against the line above. <a href="https://bugs.debian.org/571176">Bug #571176</a>, still open, shows both cases: <code>PENG AM</code> on line 2 reported as line 1, and a lone <code>BLA</code> on line 4 reported correctly. Check the line after the one named.</li>
<li><strong>Nothing is installed.</strong> <code>crontab file</code> exits with status 1 (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L202-L205">cronie</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L124-125">Debian</a>), and <code>crontab -e</code> asks whether to retry, covered <a href="#do-you-want-to-retry-the-same-edit">below</a>.</li>
</ul>
<h2>bad minute, bad hour, bad day-of-month, bad month, bad day-of-week</h2>
<p>These five name the field crontab was parsing when it gave up (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L228-L276">cronie entry.c</a>). Both implementations accept the same values in each field (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/macros.h#L112-L131">cronie macros.h</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/cron.h#L122-140">Debian cron.h</a>); <a href="https://cronradar.com/blog/cron-expression#the-five-fields">cron expression syntax</a> lists them and the operators. What triggers the messages in practice:</p>
<ul>
<li><strong>A value out of range</strong>, such as <code>60</code> for a minute, <code>24</code> for an hour, <code>0</code> or <code>32</code> for a day of month, <code>13</code> for a month, or <code>8</code> for a day of week.</li>
<li><strong>A full name.</strong> Names are three letters in any case, so <code>Jan</code> and <code>mon</code> work and <code>January</code> and <code>Monday</code> don't (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L723-L733">cronie entry.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/cron.h#L244-252">Debian cron.h</a>).</li>
<li><strong>Quartz or Spring syntax.</strong> <code>?</code>, <code>L</code>, <code>W</code>, <code>5L</code>, and <code>MON#1</code> aren't cron syntax; <a href="https://cronradar.com/blog/cron-last-day-of-month">cron last day of month</a> shows how to get the effect of <code>L</code>. Debian reads every letter and digit into the value, so <code>L</code>, <code>W</code>, and <code>5L</code> fail there too (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L449-489">entry.c</a>). After a valid value, though, it skips any other characters up to the next blank (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L344">L344</a>), so <code>0 9 * * MON#1</code> installs there as every Monday, while cronie rejects it.</li>
<li><strong>A seconds field.</strong> Spring and Quartz expressions begin with seconds, so every value lands one field early: <code>0 30 2 * * *</code>, meant as 02:30:00, puts <code>30</code> in the hour field and fails with <code>bad hour</code>.</li>
<li><strong>A step without a range.</strong> <code>5/15</code> is <code>bad minute</code> in both (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L612-L626">cronie entry.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/crontab-entry-parsing-fixes.patch#L43-47">Debian patch</a>); write <code>5-59/15</code>. BusyBox accepts <code>5/15</code> without a message and runs only at minute 5 (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L283-L311">crond.c</a>).</li>
<li><strong>A step of zero</strong>, as in <code>*/0</code> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L596-L603">cronie</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/crontab-entry-parsing-fixes.patch#L57">Debian patch</a>).</li>
<li><strong>A line that isn't a valid variable.</strong> Any line that isn't blank, a comment, or <code>NAME=value</code> is parsed as a job, so its first word is read as the minute field (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/env.c#L182-L276">cronie env.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Backport-crontab-envparser-from-v4.1.patch#L100-125">Debian patch</a>). <code>export PATH=/usr/local/bin:/usr/bin:/bin</code>, <code>source ~/.profile</code>, and <code>MAILTO ops@example.com</code> without its <code>=</code> all fail as <code>bad minute</code>. Write <code>PATH=/usr/local/bin:/usr/bin:/bin</code>; <a href="https://cronradar.com/blog/cron-environment-variables">cron environment variables</a> covers what a variable line can and can't do.</li>
</ul>
<h3>The Step-Size Warning Is Not an Error</h3>
<pre><code class="language-text">Warning: Step size 90 higher than possible maximum of 59
</code></pre>
<p>cronie and Debian print this for a step larger than the span of the range it steps through, which for <code>*</code> is the whole field, then install the line anyway (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L680-L684">cronie entry.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Step-size_out_of_bound_handling.patch#L89">Debian patch</a>). <code>*/90 * * * *</code> then runs at minute 0 of every hour, because the next step lands past 59. A 90-minute interval takes two lines:</p>
<pre><code class="language-text">0 0-21/3 * * * /opt/app/sync.sh
30 1-22/3 * * * /opt/app/sync.sh
</code></pre>
<p>The first runs at 00:00, 03:00, and so on through 21:00; the second at 01:30, 04:30, and so on through 22:30.</p>
<h2>bad command</h2>
<p>crontab doesn't check that the command exists. cronie prints <code>bad command</code> when nothing follows the time fields, or when the command would start with <code>*</code> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L284-L289">entry.c</a>); Debian's checks only for the <code>*</code> (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/features/Better-timeskip-handling.patch#L489-491">Better-timeskip-handling.patch</a>). cronie's source says only that the <code>*</code> check catches "a common typo". Among the lines it rejects is a six-field expression whose sixth field is <code>*</code>, like this Spring one:</p>
<pre><code class="language-text"># "Every 5 minutes" in Spring's six-field syntax: rejected with bad command
0 */5 * * * * /opt/app/poll.sh

# The five-field cron equivalent
*/5 * * * * /opt/app/poll.sh
</code></pre>
<p>Only a <code>*</code> is caught. Any other sixth field becomes the start of the command, so the Quartz expression <code>0 0 2 * * ? /opt/app/report.sh</code> installs without an error and runs the command <code>? /opt/app/report.sh</code> at 00:00 on the 2nd of every month. Cron's smallest interval is a minute; <a href="https://cronradar.com/blog/cron-every-30-seconds">cron every 30 seconds</a> covers the workarounds.</p>
<h2>Other Messages That Refuse a Crontab</h2>
<table>
<thead>
<tr>
<th>Message</th>
<th>Printed by</th>
<th>Cause and fix</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>bad time specifier</code></td>
<td>cronie, Debian</td>
<td><code>@</code> followed by anything other than <code>reboot</code>, <code>yearly</code>, <code>annually</code>, <code>monthly</code>, <code>weekly</code>, <code>daily</code>, <code>midnight</code>, or <code>hourly</code>, in lowercase; <code>@Daily</code> and <code>@every</code> fail (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L156-L215">cronie</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L110-157">Debian</a>)</td>
</tr>
<tr>
<td><code>bad option</code></td>
<td>cronie</td>
<td>A <code>-</code> option before the command other than a single <code>-n</code>, or a line starting with <code>-</code> in a non-root user's crontab (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L138-L148">entry.c</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L420-L448">L420-L448</a>)</td>
</tr>
<tr>
<td><code>unsafe mail</code></td>
<td>Debian 13</td>
<td>A <code>MAILTO</code> or <code>MAILFROM</code> value that doesn't start with a letter or digit, or contains a character other than letters, digits, and <code>@!:%-.,_+=/</code>. <code>MAILTO=a@example.com, b@example.com</code> fails on the space; remove it (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/features/check_e_mails.patch#L35-48">check_e_mails.patch</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/features/check_e_mails.patch#L113-119">safe_p</a>)</td>
</tr>
<tr>
<td><code>command too long</code></td>
<td>Debian</td>
<td>A command of 999 characters or more; move it into a script (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Dont-silently-truncate-commands.patch#L47-67">Dont-silently-truncate-commands.patch</a>)</td>
</tr>
<tr>
<td><code>crontab is too long; maximum number of lines is 10000.</code></td>
<td>Debian</td>
<td>More than 10,000 lines (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Enforce-maximum-crontab-line-count.patch#L86">Enforce-maximum-crontab-line-count.patch</a>)</td>
</tr>
<tr>
<td><code>There are too many entries in the crontab file. Limit: 10000</code></td>
<td>cronie</td>
<td>More than 10,000 entries; a matching message caps variables at 1,000 (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L1107-L1119">crontab.c</a>)</td>
</tr>
</tbody>
</table>
<h2>premature EOF and the Other Missing-Newline Messages</h2>
<p>A crontab whose last line has no newline is refused by both <code>crontab</code> commands; the <a href="https://cronradar.com/blog/crontab-guide#comments-blank-lines-and-the-final-newline">crontab guide</a> explains why and how to test a file for it. The message depends on which program reads the file:</p>
<table>
<thead>
<tr>
<th>Reader</th>
<th>Message</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>cronie <code>crontab</code></td>
<td><code>"FILE":N: premature EOF</code>, then <code>Invalid crontab file, can't install.</code></td>
<td>Refused; <code>N</code> is the last line</td>
</tr>
<tr>
<td>Debian <code>crontab</code></td>
<td><code>new crontab file is missing newline before EOF, can't install.</code></td>
<td>Refused</td>
</tr>
<tr>
<td>cronie <code>crond</code></td>
<td><code>(CRON) missing newline before EOF (/etc/cron.d/app)</code></td>
<td>The last line is skipped</td>
</tr>
<tr>
<td>Debian <code>cron</code></td>
<td><code>(*system*app) ERROR (Missing newline before EOF, this crontab file will be ignored)</code></td>
<td>The whole file is ignored</td>
</tr>
<tr>
<td>BusyBox</td>
<td>None</td>
<td>The last line is read normally</td>
</tr>
</tbody>
</table>
<p>Sources: cronie <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L1084-L1090">crontab.c</a> and <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c#L127-L136">user.c</a>; Debian <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/crontab-detect-missing-eol.patch#L41-46">crontab-detect-missing-eol.patch</a> and <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Abort-processing-of-crontabs-with-errors.patch#L29-32">Abort-processing-of-crontabs-with-errors.patch</a>.</p>
<p>A comment as the last line triggers none of these, because both parsers read a comment through to the end of the file before looking for another entry (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/misc.c#L416-L458">cronie misc.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/env.c#L123-124">Debian env.c</a>). The crontab guide's <a href="https://cronradar.com/blog/crontab-guide#crontab-file-replaces-everything">install script</a> adds the newline only when it's missing.</p>
<h2>Do You Want to Retry the Same Edit?</h2>
<p>cronie and Debian ask this after <code>crontab -e</code> saves a file that fails the check, missing newline included; the <a href="https://cronradar.com/blog/crontab-guide#syntax-is-checked-when-you-save">crontab guide</a> covers what <code>y</code> and <code>n</code> do, and that BusyBox never asks. cronie's prompt ends in <code>(Y/N)</code> and Debian's in <code>(y/n)</code>, both take either case, and any other answer prints <code>Enter Y or N</code> and asks again (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L874-L897">cronie crontab.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L455-474">Debian crontab.c</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Improve-retry-prompt.patch#L22">Improve-retry-prompt.patch</a>). After <code>n</code>, crontab prints <code>crontab: edits left in /tmp/crontab.Xq3fLa</code> (on Debian, a path ending in <code>/crontab</code>); fix that file and install it with <code>crontab /tmp/crontab.Xq3fLa</code>.</p>
<p>The installed crontab is untouched until a check passes. Two consequences for automation: <code>crontab -e</code> exits with status 0 even after <strong>n</strong> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c#L188-L201">cronie</a>, <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/crontab.c#L116-123">Debian</a>), and neither implementation stops asking when standard input is closed. cronie reprints the prompt when the read fails, and Debian answers its own empty read with <code>Enter Y or N</code>, so a script that drives <code>crontab -e</code> through <code>$EDITOR</code> loops forever on a syntax error. Scripts should install with <code>crontab file</code>, which exits 1 instead; the <a href="https://cronradar.com/blog/crontab-guide#crontab-file-replaces-everything">crontab guide</a> has a script that adds an entry that way without dropping the others.</p>
<h2>When the Daemon Finds the Error</h2>
<p>Nothing checks <code>/etc/crontab</code> or <code>/etc/cron.d</code> files before the daemon reads them, and the same goes for a user crontab copied into the spool directory instead of installed with <code>crontab</code>. The daemon logs the reason and the file, but not the line:</p>
<table>
<thead>
<tr>
<th>Daemon</th>
<th>Log messages</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td>cronie crond</td>
<td><code>(CRON) bad minute (/etc/cron.d/app)</code></td>
<td>That line is skipped; the rest runs</td>
</tr>
<tr>
<td>Debian cron</td>
<td><code>Error: bad minute; while reading /etc/cron.d/app</code>, then <code>(*system*app) ERROR (Syntax error, this crontab file will be ignored)</code></td>
<td>Every job in the file stops</td>
</tr>
</tbody>
</table>
<p>Sources: cronie <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c#L137-L150">user.c</a> and <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/misc.c#L527-L529">misc.c</a>; Debian <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/features/Logging-enhancements.patch#L80-86">Logging-enhancements.patch</a> and <a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/patches/fixes/Abort-processing-of-crontabs-with-errors.patch#L44-46">Abort-processing-of-crontabs-with-errors.patch</a>. For <code>/etc/crontab</code>, Debian logs <code>while reading /etc/crontab</code> and <code>(*system*)</code>; for a user's crontab, <code>while reading crontab for user alice</code> and <code>(alice)</code>.</p>
<p>The user field has its own failures, since system crontab lines carry one and user crontabs don't. Debian reads the first word after the time fields as the account and logs <code>bad username</code> when no such account exists, which is what a line missing its user field usually produces (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/entry.c#L224-240">entry.c</a>). cronie loads such a line and, at each scheduled time, logs <code>getpwnam() failed - user unknown</code> instead of running it (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L294-L316">entry.c</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/job.c#L55-L77">job.c</a>); with a one-word command, the line is <code>bad command</code> instead.</p>
<p>Find the messages in the journal. Debian's unit is <code>cron</code> (<a href="https://salsa.debian.org/debian/cron/-/blob/debian/3.0pl1-197/debian/cron.service">cron.service</a>) and Fedora installs cronie's as <code>crond</code> (<a href="https://src.fedoraproject.org/rpms/cronie/blob/rawhide/f/cronie.spec#_125">cronie.spec</a>):</p>
<pre><code class="language-bash">journalctl -u cron --since today | grep -E 'Error: |ERROR \('   # Debian, Ubuntu
journalctl -u crond --since today | grep -F '(CRON)'            # Fedora, RHEL
</code></pre>
<p>To find the line, check a copy of the file with crontab's own parser. This drops the user field from each job line, keeps every other line as it is so line numbers still match, and installs nothing:</p>
<pre><code class="language-bash"># Run as root. Field 6 is the user on a time-field line; field 2 on an @ line.
awk '/^[[:space:]]*(#|$)/ || /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/ { print; next }
     /^[[:space:]]*@/ { $2 = ""; print; next }
     { $6 = ""; print }' /etc/cron.d/app > /tmp/app-check.cron
</code></pre>
<p>Then check the copy with your implementation's syntax-check option and delete it. The <a href="https://cronradar.com/blog/crontab-guide#the-crontab-options-at-a-glance">crontab guide's options table</a> lists which versions have <code>-n</code> and <code>-T</code>, and what <code>-n</code> does on cronie instead, so run only your implementation's line:</p>
<pre><code class="language-bash">crontab -n /tmp/app-check.cron &#x26;&#x26; rm /tmp/app-check.cron   # Debian, Ubuntu
crontab -T /tmp/app-check.cron &#x26;&#x26; rm /tmp/app-check.cron   # cronie
</code></pre>
<p>The copy can't reveal a missing final newline, because awk ends every line with one, or a missing user field, which Debian's log names as <code>bad username</code> and cronie's as <code>getpwnam() failed - user unknown</code>, or as <code>bad command</code> for a one-word command.</p>
<h2>BusyBox: No Check, One Log Message</h2>
<p>BusyBox's <code>crontab</code> copies the file into place without parsing it (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crontab.c#L176-L191">crontab.c</a>), so it never prints a syntax error. crond parses the file when it next loads it, and for a field it can't read it logs one line (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L321-L324">crond.c</a>):</p>
<pre><code class="language-text">user root: parse error at 60
</code></pre>
<p><code>root</code> is the crontab's file name, which BusyBox uses as its user name (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L445">L445</a>), and <code>60</code> is the field's text. The line stays loaded, and what the field then matches depends on where parsing stopped:</p>
<ul>
<li><strong>An out-of-range number fills the whole field.</strong> The fill loop wraps around until a failsafe ends it (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L296-L311">crond.c</a>), so <code>60</code> in the minute field runs every minute, and <code>7</code> in the day-of-week field runs every day, as <a href="https://cronradar.com/blog/cron-implementations#busybox-crond-three-variables-and-one-directory">cron implementations</a> notes.</li>
<li><strong>An unrecognized character leaves the field empty.</strong> <code>L</code> or <code>?</code> stops parsing before anything is set (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L270-L273">crond.c</a>). In the minute, hour, or month field, the line then never runs. In a day field, it never runs only if the other day field is <code>*</code>, because BusyBox runs a line when either day field matches: <code>0 9 ? * MON</code> still runs every Monday (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L336-L360">L336-L360</a>, <a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L912">L912</a>).</li>
</ul>
<p>Other mistakes produce no message at all:</p>
<ul>
<li>A line with fewer than six fields, such as one with no command, is skipped (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L546-L547">crond.c</a>).</li>
<li>An unknown <code>@</code> keyword is skipped (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L542">L542</a>).</li>
<li>A variable other than <code>MAILTO</code>, <code>SHELL</code>, or <code>PATH</code> is skipped because it has fewer than six fields (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L546-L547">L546-L547</a>). One with six or more words, such as <code>MSG=a b c d e f</code>, is read as a job instead and logs <code>parse error</code>.</li>
<li>A crontab not owned by root is ignored (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L440">L440</a>). One named after an account that doesn't exist is logged only at log level 7 or lower, and the default is 8 (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L429-L431">L429-L431</a>, <a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L168">L168</a>, <a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L178">L178</a>).</li>
</ul>
<p>A non-root crontab is also cut off at <code>MAXLINES</code>, 256 by default, with <code>user alice: too many lines</code> (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L103-L104">L103-L104</a>, <a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L451-L453">L451-L453</a>). crond logs to syslog unless started with <code>-L FILE</code>, or with <code>-d N</code>, which logs to standard error (<a href="https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L1048-L1052">crond.c</a>).</p>
<h2>Catch the Jobs a Bad Crontab Stops</h2>
<p>A refused <code>crontab</code> keeps the old crontab, so a job you meant to add doesn't exist and a job you meant to change keeps its old schedule. On Debian and Ubuntu, one bad line in <code>/etc/crontab</code>, an <code>/etc/cron.d</code> file, or a crontab written outside <code>crontab</code> stops every job in that file, and the only trace is two log lines. Nothing on the host raises an alert.</p>
<p>A check-in monitor notices from outside. With CronRadar, each run reports <code>start</code>, then <code>complete</code> or <code>fail</code>; a <code>fail</code> opens an incident immediately, and a run that hasn't reported by its scheduled time turns its monitor <code>warning</code>, then <code>critical</code> once the grace period runs out. Alerts go by email, Slack, webhook, Discord, or PagerDuty. There's no CronRadar SDK or auto-discovery for crontab, so each job reports with its own HTTP check-in. <a href="https://cronradar.com/blog/cron-grace-period">Cron grace period</a> has a wrapper script that reads the API key from a file only the job's account can read, sends it with HTTP Basic auth, and exits with the job's own status, so the key never appears in the crontab. CronRadar reads every schedule as five fields in UTC, so convert the crontab line's times to UTC, write <code>@</code> shortcuts out as fields, and use <code>0</code>, not <code>7</code>, for Sunday. An entry that restricts both day fields needs two entries, each with its own job key, because CronRadar expects a run only on dates that match both fields, where cron runs on either (<a href="https://docs.cronradar.com/schedules">schedules</a>).</p>
<p>A monitor created by the job's first check-in doesn't exist until that check-in arrives, so a line that never installed has nothing to go <code>critical</code>. Register each job before its first run with <a href="https://docs.cronradar.com/api-reference#framework-registry-sync"><code>POST /sync</code></a> in <code>upsert</code> mode instead: a monitor that never receives a check-in then turns <code>critical</code>, with a <code>NeverExecuted</code> alert, once its first expected time plus the grace period has passed.</p>
<h2>Frequently Asked Questions</h2>
<h3>What does "errors in crontab file, can't install" mean?</h3>
<p>Debian's or Ubuntu's <code>crontab</code> found a line it couldn't parse, printed it above this message as <code>"FILE":LINE: reason</code>, and kept your current crontab. Fix that line and install again; only the first bad line is reported per check. cronie prints <code>Invalid crontab file, can't install.</code> for the same thing.</p>
<h3>What does "bad minute" mean in crontab?</h3>
<p>crontab couldn't read the first field of a line. Common causes are a value outside 0-59, a step without a range such as <code>5/15</code>, and a line that is neither a job nor a valid <code>NAME=value</code> variable, such as <code>export PATH=...</code>, whose first word is read as the minute.</p>
<h3>Why does crontab say "premature EOF"?</h3>
<p>cronie's <code>crontab</code> prints it when the file's last line has no newline. Add one with <code>echo >> file</code> and install again. Debian's <code>crontab</code> reports the same problem as <code>new crontab file is missing newline before EOF, can't install.</code></p>
<h3>How do I answer "Do you want to retry the same edit?"</h3>
<p>Answer <code>y</code> to reopen the editor with your changes and fix the line named above the prompt. Answer <code>n</code> to quit; crontab keeps your edits in the temporary file it names, and the installed crontab is unchanged.</p>
<h3>Why doesn't crontab on Alpine report errors?</h3>
<p>BusyBox's <code>crontab</code> installs files without parsing them. crond reports a bad field later in its log, as <code>user root: parse error at 60</code>, and silently skips lines with no command, unknown <code>@</code> keywords, and variable lines of fewer than six words other than <code>MAILTO</code>, <code>SHELL</code>, and <code>PATH</code>.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>errors in crontab file</category>
      <category>crontab bad minute</category>
      <category>crontab premature EOF</category>
      <category>do you want to retry the same edit</category>
      <category>crontab syntax error</category>
    </item>
    <item>
      <title><![CDATA[GitLab Scheduled Pipelines: Delays, Inactive Schedules, and Alerts]]></title>
      <link>https://cronradar.com/blog/gitlab-scheduled-pipelines</link>
      <guid>https://cronradar.com/blog/gitlab-scheduled-pipelines</guid>
      <description><![CDATA[How GitLab pipeline schedules work: cron and time zone, why scheduled pipelines start late, why schedules go inactive, schedule rules, and alerts.]]></description>
      <content:encoded><![CDATA[<p><strong>A GitLab pipeline schedule runs your <code>.gitlab-ci.yml</code> pipeline on a five-field cron pattern for a branch or tag you choose; create one under Build > Pipeline schedules.</strong> It doesn't start on the minute. An internal pipeline schedule worker queues due pipelines only when it runs, every 5 minutes on GitLab.com and every 10 minutes on a default self-managed instance; on GitLab.com Free, a schedule whose minute isn't a multiple of 5 waits for the next full hour. GitLab says load can push a start "several minutes to over an hour after their scheduled time". A schedule also goes inactive when its owner is blocked or removed from the project, and an inactive schedule produces no pipeline to fail.</p>
<p>This guide covers GitLab 19.5.3, the current release on <a href="https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-3-released/">GitLab's release notes</a> (checked October 30, 2026), and cites the <a href="https://docs.gitlab.com/19.5/">GitLab 19.5 documentation</a> for GitLab.com and self-managed instances. The same problems on other CI systems are covered in <a href="https://cronradar.com/blog/github-actions-scheduled-workflows">GitHub Actions scheduled workflows</a> and <a href="https://cronradar.com/blog/jenkins-scheduled-builds">Jenkins scheduled builds</a>.</p>
<h2>Create a Pipeline Schedule</h2>
<p>In the project, select <strong>Build</strong> > <strong>Pipeline schedules</strong> > <strong>New schedule</strong> and fill in the fields (<a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#create-a-pipeline-schedule">scheduled pipelines</a>):</p>
<ul>
<li><strong>Interval Pattern:</strong> a preconfigured interval or a custom cron pattern.</li>
<li><strong>Target branch or tag:</strong> the ref the pipeline runs for.</li>
<li><strong>Inputs:</strong> values for the <code>spec:inputs</code> in your configuration, "used every time the scheduled pipeline runs", at most 20.</li>
<li><strong>Variables:</strong> CI/CD variables "available only when the scheduled pipeline runs". GitLab recommends inputs instead, and a project setting can refuse schedule variables altogether (see the invalid-configuration section below).</li>
</ul>
<p>You need the Developer, Maintainer, or Owner role, a verified primary email, merge permission if the target branch is protected, and a <code>.gitlab-ci.yml</code> with valid syntax. Whoever creates the schedule becomes its owner, and "the pipeline runs with your permissions." A project can hold 10 schedules by default (<a href="https://docs.gitlab.com/19.5/administration/cicd/limits/#number-of-pipeline-schedules">number of pipeline schedules</a>); GitLab.com allows 10 on Free and 50 on paid tiers (<a href="https://docs.gitlab.com/19.5/user/gitlab_com/#cicd">GitLab.com settings</a>).</p>
<h3>Cron Syntax and Time Zone</h3>
<p>The pattern has five fields: minute, hour, day of month, month, and day of week, 0 to 6 for Sunday to Saturday (<a href="https://docs.gitlab.com/19.5/topics/cron/">cron syntax</a>). GitLab parses it with fugit, which also accepts <code>0 0 * * 1#2</code> for the second Monday and <code>0 9 * * sun%2</code> for every other Sunday, syntax GitLab warns "might not work in all cron testers" (<a href="https://docs.gitlab.com/19.5/topics/cron/#how-gitlab-parses-cron-syntax-strings">how GitLab parses cron</a>). <a href="https://cronradar.com/blog/cron-expression">Cron expression syntax</a> covers the standard fields and how other schedulers' dialects differ.</p>
<p>Each schedule carries its own zone. The <a href="https://docs.gitlab.com/19.5/api/pipeline_schedules/#create-a-new-pipeline-schedule">pipeline schedules API</a> calls it <code>cron_timezone</code>, a "time zone supported by <code>ActiveSupport::TimeZone</code>, for example: <code>Pacific Time (US &#x26; Canada)</code>", with a default of <code>UTC</code>. To create a schedule in UTC with an access token in <code>GITLAB_TOKEN</code>:</p>
<pre><code class="language-bash">curl --request POST \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --form "description=Nightly report" \
  --form "ref=main" \
  --form "cron=17 3 * * *" \
  --form "cron_timezone=UTC" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules"
</code></pre>
<p>GitLab's documentation doesn't say what a schedule in a zone with daylight saving does in the hour that is skipped in spring or repeated in autumn. <a href="https://cronradar.com/blog/cron-daylight-saving-time">Cron and daylight saving time</a> shows how other schedulers differ there; a <code>cron_timezone</code> of <code>UTC</code> avoids the question.</p>
<h2>How Often GitLab Actually Starts a Scheduled Pipeline</h2>
<p>GitLab's limits page explains the gap. Scheduled pipelines "do not always run exactly when scheduled. An internal process, called the 'pipeline schedule worker', queues all the scheduled pipelines, but does not run continuously." A due pipeline is "only queued the next time the worker runs", and "scheduled pipelines can't run more frequently than the worker" (<a href="https://docs.gitlab.com/19.5/administration/cicd/limits/#maximum-scheduled-pipeline-frequency">maximum scheduled pipeline frequency</a>).</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>GitLab.com</th>
<th>Self-managed default</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pipeline schedule worker</td>
<td><code>*/5 * * * *</code></td>
<td><code>3-59/10 * * * *</code> (:03, :13, :23, and so on)</td>
</tr>
<tr>
<td>Pipelines per schedule per day</td>
<td>24 on Free (once an hour at most), 288 on paid (every 5 minutes)</td>
<td><code>0</code>, meaning no daily limit</td>
</tr>
</tbody>
</table>
<p>The GitLab.com values come from its <a href="https://docs.gitlab.com/19.5/user/gitlab_com/#cicd">CI/CD settings</a>. On self-managed, <code>ci_daily_pipeline_schedule_triggers</code> defaults to <code>0</code> (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/db/structure.sql#L28299"><code>db/structure.sql</code></a>), and GitLab ignores a limit that isn't positive (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/models/plan_limits.rb#L57-65"><code>plan_limits.rb</code></a>), so only the worker sets the pace. What they do to a schedule:</p>
<ul>
<li><strong>The worker adds a wait.</strong> A <code>17 3 * * *</code> schedule is queued at the 03:20 worker run on GitLab.com paid tiers, and at 03:23 on a default self-managed instance. On GitLab.com Free, the 24-a-day limit moves it to 04:00: GitLab keeps the schedule's own time only when a worker run falls on it, and otherwise uses the next full hour (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/calculate_next_run_service.rb#L11-25">next-run calculation</a>). GitLab's tests expect a <code>59 14 * * *</code> schedule under that limit to run at 15:00 (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/spec/services/ci/pipeline_schedules/calculate_next_run_service_spec.rb#L76">spec</a>), while <code>15 3 * * *</code> runs at 03:15.</li>
<li><strong>Schedules faster than the limits are slowed.</strong> "Schedules that try to run pipelines more frequently than the limit are slowed to a maximum frequency": 1,440 minutes divided by the daily limit (<a href="https://docs.gitlab.com/19.5/administration/cicd/limits/#limit-the-number-of-pipelines-created-by-a-pipeline-schedule-each-day">pipelines per schedule</a>). With GitLab.com Free's 24, that's once every 60 minutes, so a <code>*/15 * * * *</code> schedule runs at most hourly. On a default self-managed instance, a <code>*/5</code> schedule can't run more often than the worker's 10 minutes.</li>
<li><strong>Load adds more.</strong> The worker processes schedules in batches "with a small delay between each batch to distribute system load. This can cause pipeline schedules to start several minutes to over an hour after their scheduled time, depending on system load."</li>
</ul>
<p>Then the pipeline's jobs wait for a runner like any others. A pending job is dropped after 1 hour with no matching runners (<code>stuck_pending_no_matching_runners</code>) and after 24 hours with matching ones (<code>stuck_pending_with_matching_runners</code>) (<a href="https://docs.gitlab.com/19.5/ci/jobs/job_troubleshooting/#job-dropped-after-being-stuck-or-exceeding-its-timeout">dropped jobs</a>). On instance runners with a compute quota, which GitLab.com enables for Free namespaces at 400 minutes a month (<a href="https://docs.gitlab.com/19.5/ci/pipelines/compute_minutes/">compute minutes</a>), a used-up quota means "instance runners stop processing new jobs", and GitLab notes this "can look like a runner availability problem" (<a href="https://docs.gitlab.com/19.5/ci/pipelines/instance_runner_compute_minutes/#enforcement">enforcement</a>).</p>
<p><strong>Stagger schedules.</strong> GitLab's troubleshooting advice is to replace popular patterns such as <code>0 * * * *</code> with ones spread across the hour, like <code>5 * * * *</code>, <code>15 * * * *</code>, and <code>25 * * * *</code> (<a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#distribute-pipeline-schedules-to-prevent-system-load">distribute pipeline schedules</a>). <a href="https://cronradar.com/blog/cron-midnight-load-spreading">Cron jitter</a> covers choosing offsets; on GitLab.com Free, keep them to multiples of 5 minutes so a worker run falls on them.</p>
<p><strong>On self-managed, the limits are yours.</strong> Set <code>gitlab_rails['pipeline_schedule_worker_cron']</code> in <code>gitlab.rb</code> and reconfigure GitLab to change the worker; GitLab's example, <code>0 */12 * * *</code>, caps every schedule at twice a day. To add a daily limit, set it in the Rails console with <code>Plan.default.actual_limits.update!(ci_daily_pipeline_schedule_triggers: 1440)</code>, where 1440 allows one pipeline a minute, and <code>Plan.default.actual_limits</code> <a href="https://docs.gitlab.com/19.5/administration/instance_limits/#list-all-instance-limits">lists the current values</a>.</p>
<h3>Measure the Delay</h3>
<p>The API lists each pipeline a schedule created, with its <code>created_at</code> (<a href="https://docs.gitlab.com/19.5/api/pipeline_schedules/#list-all-pipelines-triggered-by-a-pipeline-schedule">pipelines of a schedule</a>). For the daily <code>17 3 * * *</code> UTC schedule, this prints how many minutes after 03:17 each recent pipeline was created:</p>
<pre><code class="language-bash">curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules/$SCHEDULE_ID/pipelines?sort=desc" \
  | jq '.[].created_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 | (. % 86400 - (3 * 3600 + 17 * 60)) / 60 | floor'
</code></pre>
<p>That covers the worker wait and batching. The runner wait comes after it, and the <code>start</code> check-in further down measures both. A day missing from the list is a day the schedule created no pipeline.</p>
<h2>Why a Pipeline Schedule Stops Running</h2>
<table>
<thead>
<tr>
<th>Cause</th>
<th>What you see</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>The owner was blocked or removed from the project</td>
<td><code>Inactive</code>, and an email to Owners and Maintainers</td>
<td>A Maintainer or Owner selects <strong>Take ownership</strong>, then edits the schedule and selects <strong>Activated</strong></td>
</tr>
<tr>
<td>The schedule was deactivated</td>
<td><code>"active": false</code> in the API</td>
<td>Update it with <code>active=true</code></td>
</tr>
<tr>
<td>The project or group is marked for deletion</td>
<td>No scheduled pipelines</td>
<td><a href="https://docs.gitlab.com/19.5/user/project/working_with_projects/#restore-a-project">Restore it</a></td>
</tr>
<tr>
<td><code>workflow:rules</code> or job <code>rules</code> add no jobs for <code>schedule</code></td>
<td>No pipeline at the scheduled time</td>
<td>Add a rule for <code>$CI_PIPELINE_SOURCE == "schedule"</code></td>
</tr>
<tr>
<td>The target branch's configuration is invalid</td>
<td>A pipeline with a <code>yaml invalid</code> badge</td>
<td>Fix the file and validate before merging</td>
</tr>
<tr>
<td>No runner takes the jobs</td>
<td>Jobs <code>pending</code>, then dropped</td>
<td>Check runner availability, tags, and compute quota</td>
</tr>
</tbody>
</table>
<p><strong>Ownership.</strong> "Scheduled pipelines execute with the permissions of the user who owns the schedule." If one turns <code>Inactive</code> unexpectedly, "the schedule owner might have been blocked or removed from the project" (<a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#scheduled-pipeline-becomes-inactive">troubleshooting</a>). Before each run, GitLab checks that the owner can still create pipelines in the project; if not, it creates no pipeline, emails the project's Owners and Maintainers, and deactivates the schedule (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L51-55"><code>run_pipeline_schedule_worker.rb</code></a>). The docs say taking ownership reactivates it, but in 19.5.3 the UI and the API only change the owner (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/take_ownership_service.rb#L14"><code>take_ownership_service.rb</code></a>, <a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/lib/api/ci/pipeline_schedules.rb#L228-239"><code>pipeline_schedules.rb</code></a>). After <strong>Take ownership</strong>, <a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#edit-a-pipeline-schedule">edit the schedule</a>, select <strong>Activated</strong>, and save, or <a href="https://docs.gitlab.com/19.5/api/pipeline_schedules/#update-a-pipeline-schedule">update it</a> with <code>active=true</code>. <strong>Take ownership</strong> requires the Maintainer or Owner role; the API equivalent is <code>POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership</code> (<a href="https://docs.gitlab.com/19.5/api/pipeline_schedules/#update-ownership-of-a-pipeline-schedule">ownership API</a>). Before someone leaves, they can find every active schedule they own under their avatar > <strong>Edit profile</strong> > <strong>Account</strong> > <strong>Scheduled pipelines you own</strong>, available since GitLab 18.4 (<a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#view-your-scheduled-pipelines">your scheduled pipelines</a>).</p>
<p><strong>A manual run uses your permissions.</strong> "When you run a scheduled pipeline manually, it uses your permissions instead of the schedule owner's permissions" (<a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#run-manually">run manually</a>), so its jobs get your access, not the owner's, to things like <a href="https://docs.gitlab.com/19.5/ci/pipelines/schedules/#create-a-pipeline-schedule">protected environments and the CI/CD job token</a>. GitLab still checks the owner first: if the owner can no longer create pipelines, a manual run creates no pipeline and deactivates the schedule (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/play_service.rb#L16"><code>play_service.rb</code></a>, <a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L51-55"><code>run_pipeline_schedule_worker.rb</code></a>). The API's <code>play</code> endpoint runs a schedule immediately, and "the next scheduled run of this pipeline is not affected."</p>
<p>To list a project's inactive schedules and who owns them, use the <code>scope</code> filter of the <a href="https://docs.gitlab.com/19.5/api/pipeline_schedules/#list-all-pipeline-schedules">list endpoint</a>:</p>
<pre><code class="language-bash">curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules?scope=inactive" \
  | jq -r '.[] | [.id, .description, .cron, .cron_timezone, .owner.username] | @tsv'
</code></pre>
<p><a href="https://cronradar.com/blog/scheduled-job-inventory">Scheduled job inventory</a> covers collecting schedules across projects.</p>
<h2>Rules and Workflow Conditions for Scheduled Pipelines</h2>
<p><code>CI_PIPELINE_SOURCE</code> is <code>schedule</code> in scheduled pipelines (<a href="https://docs.gitlab.com/19.5/ci/jobs/job_rules/#ci_pipeline_source-predefined-variable">pipeline sources</a>). GitLab's own example puts one job in scheduled pipelines only and another in push pipelines only (<a href="https://docs.gitlab.com/19.5/ci/jobs/job_rules/#run-jobs-for-scheduled-pipelines">run jobs for scheduled pipelines</a>):</p>
<pre><code class="language-yaml">nightly-report:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - ./scripts/nightly-report.sh

build:
  rules:
    - if: $CI_PIPELINE_SOURCE == "push"
  script:
    - make build
</code></pre>
<p>The conditions that decide whether a schedule produces anything:</p>
<ul>
<li><strong><code>workflow:rules</code> come first.</strong> "The <code>workflow</code> keyword is evaluated before jobs" (<a href="https://docs.gitlab.com/19.5/ci/yaml/workflow/#workflow-rules-examples">workflow</a>), and "when no rules evaluate to true, the pipeline does not run" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#workflowrules"><code>workflow:rules</code></a>). A workflow that allows only <code>push</code> and <code>merge_request_event</code> blocks every schedule; add <code>- if: $CI_PIPELINE_SOURCE == "schedule"</code>.</li>
<li><strong>No jobs, no pipeline.</strong> "A pipeline does not run if no jobs are added to it at the end of the evaluation" (<a href="https://docs.gitlab.com/19.5/ci/debugging/#no-pipeline-or-the-wrong-type-of-pipeline-runs">debugging</a>), and "if a pipeline contains only jobs in the <code>.pre</code> or <code>.post</code> stages, it does not run."</li>
<li><strong>Branch rules match schedules too.</strong> A branch pipeline "can be triggered by a change to a branch, an API call, or a scheduled pipeline" (<a href="https://docs.gitlab.com/19.5/ci/yaml/workflow/#switch-between-branch-pipelines-and-merge-request-pipelines">workflow</a>), and <code>CI_COMMIT_BRANCH</code> is "available in branch pipelines, including pipelines for the default branch" (<a href="https://docs.gitlab.com/19.5/ci/variables/predefined_variables/">predefined variables</a>), so a job with <code>if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</code> also runs in schedules on the default branch. GitLab's <a href="https://docs.gitlab.com/19.5/ci/jobs/job_rules/#reuse-rules-in-different-jobs">reuse example</a> puts <code>if: $CI_PIPELINE_SOURCE == "schedule"</code> with <code>when: never</code> first to exclude them.</li>
<li><strong><code>changes</code> is always true.</strong> "All files are considered to have changed when a scheduled pipeline runs, so jobs might always be added to scheduled pipelines that use <code>changes</code>" (<a href="https://docs.gitlab.com/19.5/ci/jobs/job_troubleshooting/#jobs-or-pipelines-run-unexpectedly-when-using-changes">troubleshooting</a>).</li>
<li><strong>Several schedules, one file.</strong> <code>CI_PIPELINE_SCHEDULE_DESCRIPTION</code>, added in 17.8, holds "the description of the pipeline schedule" and is available to <code>rules</code> (<a href="https://docs.gitlab.com/19.5/ci/variables/predefined_variables/">predefined variables</a>). Inputs set on each schedule, shown at the end of this guide, don't depend on a description someone may edit.</li>
</ul>
<h2>What Happens When the Configuration Is Invalid</h2>
<p>A schedule needs valid syntax when it's created, but a later commit to its target branch can still break the file. Then "the pipeline shows a <code>yaml invalid</code> badge and does not start running" (<a href="https://docs.gitlab.com/19.5/ci/debugging/#verify-syntax">verify syntax</a>). No job runs, including any job meant to report the failure.</p>
<ul>
<li><strong>Validate before merging.</strong> The pipeline editor checks syntax automatically, and the CI Lint tool can simulate a pipeline. The simulation "runs as a Git <code>push</code> event on the default branch" (<a href="https://docs.gitlab.com/19.5/ci/yaml/lint/#simulate-a-pipeline">simulate a pipeline</a>), so jobs whose rules require <code>schedule</code> don't appear in it. To check those, select <strong>Run</strong> next to the schedule, which triggers it "immediately instead of waiting for the next scheduled time", and inspect the pipeline it creates.</li>
<li><strong>Inputs need defaults.</strong> "If any input is missing a default, the pipeline fails when it triggers automatically," for example in merge request and branch pipelines (<a href="https://docs.gitlab.com/19.5/ci/inputs/#for-a-pipeline">pipeline inputs</a>). A schedule supplies its own values, but the other pipelines in the project don't.</li>
<li><strong>Schedule variables can be refused.</strong> Variables set on a schedule are pipeline variables, and <strong>Minimum role to use pipeline variables</strong> can block them. Its <code>no_one_allowed</code> value is the default "for new projects in new namespaces on GitLab.com", and with it, "no pipelines can run with pipeline variables" (<a href="https://docs.gitlab.com/19.5/ci/variables/#restrict-pipeline-variables">restrict pipeline variables</a>). GitLab recommends inputs over pipeline variables and suggests disabling pipeline variables when you use inputs (<a href="https://docs.gitlab.com/19.5/ci/variables/#use-pipeline-variables">pipeline variables</a>).</li>
</ul>
<h2>Failed-Pipeline Notifications</h2>
<p>GitLab has two ways to email about a failed pipeline:</p>
<ul>
<li><strong>Personal notifications.</strong> "CI/CD pipeline event notifications are sent to the pipeline creator": a failed pipeline at the <strong>Watch</strong> level or with <strong>Pipeline fails</strong> selected under <strong>Custom</strong>, and a fixed one only with <strong>Pipeline is fixed</strong> under <strong>Custom</strong> (<a href="https://docs.gitlab.com/19.5/user/profile/notifications/#cicd-pipeline-events">pipeline events</a>). GitLab sends nothing to blocked, banned, or deactivated accounts.</li>
<li><strong>Pipeline status emails.</strong> Under <strong>Settings</strong> > <strong>Integrations</strong> > <strong>Pipeline status emails</strong>, a Maintainer or Owner enters a list of recipients and can select <strong>Notify only broken pipelines</strong>, <strong>Notify only when status changes</strong>, and which branches to notify for. "Pipeline notifications triggered by blocked users are not delivered" (<a href="https://docs.gitlab.com/19.5/user/project/integrations/pipeline_status_emails/">pipeline status emails</a>).</li>
</ul>
<p>Both report on pipelines that exist, so a schedule that's inactive, blocked by <code>workflow:rules</code>, or in a project marked for deletion triggers neither. When GitLab deactivates a schedule because the owner can no longer create pipelines, it does email the project's Owners and Maintainers (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L94-97"><code>run_pipeline_schedule_worker.rb</code></a>, <a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/notification_service.rb#L616-620"><code>notification_service.rb</code></a>). That gap is what a <a href="https://cronradar.com/blog/cron-dead-mans-switch">dead man's switch</a> covers: the pipeline checks in, and an outside monitor alerts when a check-in doesn't arrive on schedule.</p>
<h2>Check In to CronRadar From the Pipeline</h2>
<p>CronRadar has no SDK or integration for GitLab and doesn't discover pipeline schedules, so each schedule reports with HTTP check-ins from its own jobs.</p>
<ol>
<li><strong>Store the key.</strong> In <strong>Settings</strong> > <strong>CI/CD</strong> > <strong>Variables</strong>, add <code>CRONRADAR_API_KEY</code> with <strong>Visibility</strong> set to <strong>Masked and hidden</strong> (<a href="https://docs.gitlab.com/19.5/ci/variables/#mask-a-cicd-variable">mask</a>, <a href="https://docs.gitlab.com/19.5/ci/variables/#hide-a-cicd-variable">hide</a>). A masked value must be a single line with no spaces and at least 8 characters, which a CronRadar key, <code>ck_app_</code> followed by letters, digits, and underscores, is. GitLab replaces a masked value with <code>[MASKED]</code> in job logs, and a hidden one "cannot be revealed in the UI again." <strong>Protect variable</strong> limits the key to pipelines on protected branches and tags (<a href="https://docs.gitlab.com/19.5/ci/variables/#protect-a-cicd-variable">protect</a>), and "the default branch for your repository is protected by default" (<a href="https://docs.gitlab.com/19.5/user/project/repository/branches/protected/">protected branches</a>), so a schedule targeting it still gets the key. Keep it out of <code>.gitlab-ci.yml</code>: GitLab says tokens "should be stored in the settings in the UI, not in the <code>.gitlab-ci.yml</code> file" (<a href="https://docs.gitlab.com/19.5/ci/variables/#define-a-cicd-variable-in-the-ui">variables in the UI</a>).</li>
<li><strong>Create the monitor first.</strong> In the CronRadar dashboard, create a monitor with the key <code>nightly-report</code>, the schedule <code>17 3 * * *</code>, and a 3,600-second grace period. It stays <code>pending</code>, and if the first expected run plus the grace period passes with no check-in, CronRadar sends a never-executed alert. A monitor created by its own first check-in can't flag a schedule whose rules never let it run.</li>
<li><strong>Add the check-in jobs</strong>, with the schedule's <code>cron_timezone</code> set to <code>UTC</code>:</li>
</ol>
<pre><code class="language-yaml"># .gitlab-ci.yml
stages:
  - report

.cronradar:
  variables:
    PING_URL: https://cron.life/api/v1/ping/nightly-report
    MONITOR_SCHEDULE: '17 3 * * *' # the pipeline schedule's cron, in UTC
    GRACE_SECONDS: '3600'
  before_script:
    - |
      check_in() {
        curl --fail --silent --show-error --max-time 5 --output /dev/null \
          --request POST --get --user "$CRONRADAR_API_KEY:" \
          --data-urlencode "schedule=$MONITOR_SCHEDULE" \
          --data-urlencode "gracePeriod=$GRACE_SECONDS" \
          --data-urlencode "run_id=$CI_PIPELINE_ID" \
          "$@" || true
      }

cronradar-start:
  extends: .cronradar
  stage: .pre
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      allow_failure: true
  script:
    - check_in "$PING_URL/start"

nightly-report:
  stage: report
  timeout: 30 minutes
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - ./scripts/nightly-report.sh

cronradar-complete:
  extends: .cronradar
  stage: .post
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: on_success
      allow_failure: true
  script:
    - check_in "$PING_URL/complete"

cronradar-fail:
  extends: .cronradar
  stage: .post
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: on_failure
      allow_failure: true
  script:
    - check_in --data-urlencode "message=Scheduled pipeline failed: $CI_PIPELINE_URL" "$PING_URL/fail"
</code></pre>
<ul>
<li><strong><code>start</code> runs first.</strong> <code>.pre</code> "is the first stage in a pipeline" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#stage-pre"><code>.pre</code></a>), so <code>start</code> arrives when a runner takes the pipeline's first job, after the worker wait, any batching delay, and the runner queue.</li>
<li><strong>One result job runs.</strong> <code>when: on_success</code> runs a job "only when no jobs in earlier stages fail", and <code>when: on_failure</code> "only when at least one job in an earlier stage fails" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#ruleswhen"><code>rules:when</code></a>). Two <code>.post</code> jobs are needed because each job takes one condition. <code>fail</code> alerts immediately. Jobs with <code>allow_failure: true</code> in earlier stages "are considered successful, even if they failed" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#when"><code>when</code></a>), so their failures report <code>complete</code>.</li>
<li><strong>Timeouts report as failures.</strong> A job that runs past its <code>timeout</code> fails (<a href="https://docs.gitlab.com/19.5/ci/yaml/#timeout"><code>timeout</code></a>); without one, the project timeout applies, 60 minutes by default (<a href="https://docs.gitlab.com/19.5/ci/pipelines/settings/#set-a-limit-for-how-long-jobs-can-run">job timeout</a>). Set <code>timeout</code> a little above the longest normal run; <a href="https://cronradar.com/blog/cron-job-timeout">cron job timeouts</a> covers timeouts on the calls that hang.</li>
<li><strong>Monitoring can't fail the pipeline.</strong> Each request gives up after 5 seconds and <code>|| true</code> ignores its errors. With <code>allow_failure: true</code>, a check-in job that fails anyway shows a warning while "the pipeline is successful" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#allow_failure"><code>allow_failure</code></a>), and later stages still run. The jobs use your default image; if it has no curl, <code>|| true</code> hides the error and no check-in arrives, so give <code>.cronradar</code> an <code>image:</code> that has curl.</li>
<li><strong>The helper is shared.</strong> <code>before_script</code> and <code>script</code> "execute together in a single shell" (<a href="https://docs.gitlab.com/19.5/ci/yaml/#before_script"><code>before_script</code></a>), so each job can call <code>check_in</code>.</li>
<li><strong>One pipeline, one run ID.</strong> <code>CI_PIPELINE_ID</code> "is unique across all projects on the GitLab instance" (<a href="https://docs.gitlab.com/19.5/ci/variables/predefined_variables/">predefined variables</a>), and <code>start</code> and the result send the same value.</li>
<li><strong>Self-registering.</strong> <code>schedule</code> and <code>gracePeriod</code> apply only when a check-in creates the monitor, so a deleted monitor comes back on the next run. Change an existing monitor's values in its settings.</li>
<li><strong>No pipeline is what alerts.</strong> An inactive schedule, a pipeline blocked by rules, a <code>yaml invalid</code> pipeline, a pipeline left with only the check-in jobs (which doesn't run), and jobs that never leave <code>pending</code> all send nothing; once the expected time plus the grace period passes, the monitor goes <code>critical</code> as missed. If <code>start</code> arrives and neither result does, it goes <code>critical</code> as stuck once the next scheduled time plus the grace period passes.</li>
</ul>
<p>Alerts go to email, Slack, a webhook, Discord, or PagerDuty. In a real project, keep each existing job's own rules: the check-in jobs only need to share the pipeline with them.</p>
<h3>Choosing the Grace Period</h3>
<p>With <code>start</code> as the first job, the grace period covers only the start delay (<a href="https://cronradar.com/blog/cron-grace-period">grace periods</a> explains why). On GitLab that delay has three parts: the worker wait, up to the worker's 5 or 10 minutes, or up to an hour on GitLab.com Free for a minute that isn't a multiple of 5; batching under load, which GitLab puts at "several minutes to over an hour"; and the runner queue.</p>
<ul>
<li><strong>Daily or less often: 3,600 seconds.</strong> That absorbs a badly delayed run, and hearing about a nightly report an hour late rarely changes anything. Raise it if the <code>created_at</code> measurements above come close.</li>
<li><strong>Hourly or more often: less than the interval, more than the worker.</strong> Every check-in moves the expected time to the schedule's next occurrence, so with a grace longer than the interval, the next run checks in before a missed one is flagged; <a href="https://cronradar.com/blog/github-actions-scheduled-workflows">GitHub Actions scheduled workflows</a> works through that trade-off. For an hourly <code>17 * * * *</code> schedule on GitLab.com paid tiers or a default self-managed instance, 1,200 seconds flags each missing run, at the cost of an alert whenever a run starts more than 20 minutes late. On GitLab.com Free that schedule runs on the next full hour, 43 minutes late, so every run would alert; <code>15 * * * *</code> runs at :15 there. A grace shorter than the worker's wait turns ordinary runs into alerts.</li>
<li><strong>Send the schedule GitLab can keep.</strong> On GitLab.com Free, a <code>*/15 * * * *</code> schedule runs once an hour, on the hour, so a CronRadar monitor expecting <code>*/15</code> would go <code>critical</code> between every pair of runs. Schedule no more often than your plan's limit and your instance's worker allow, and send that cron. On Free, either use a minute that's a multiple of 5 or send CronRadar the time GitLab runs: <code>0 4 * * *</code> for <code>17 3 * * *</code>.</li>
</ul>
<h3>Schedules With a Time Zone, or Several Schedules</h3>
<p>CronRadar evaluates every schedule in UTC (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>). With <code>cron_timezone</code> set to <code>UTC</code>, send the cron unchanged. A schedule in a zone with daylight saving runs at a different UTC time in summer and winter: <code>0 6 * * *</code> in Berlin is 04:00 UTC in summer and 05:00 UTC in winter, so by CronRadar's time zone rule, which <a href="https://cronradar.com/blog/github-actions-scheduled-workflows">GitHub Actions scheduled workflows</a> works through, send <code>0 4 * * *</code> with 7,200 seconds of grace instead of 3,600.</p>
<p>CronRadar's syntax has no <code>#</code> or <code>%</code>. For fugit's second Monday, <code>0 0 * * 1#2</code>, send <code>0 0 8-14 * 1</code>, which CronRadar reads as a Monday between the 8th and the 14th because it requires a date to match both day fields. Don't schedule GitLab itself with both day fields restricted: GitLab 19.5.3 parses schedules with fugit 1.11.2 (<a href="https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/Gemfile.lock#L839"><code>Gemfile.lock</code></a>), which follows crontab(5) and runs the schedule "when either field matches" (<a href="https://github.com/floraison/fugit/blob/v1.11.2/README.md#the-first-monday-of-the-month">fugit README</a>).</p>
<p>For several schedules in one project, give each its own monitor with <a href="https://docs.gitlab.com/19.5/ci/inputs/#define-input-parameters-with-specinputs">pipeline inputs</a>. Add a header to the top of <code>.gitlab-ci.yml</code>, with defaults so push and merge request pipelines still start:</p>
<pre><code class="language-yaml">spec:
  inputs:
    monitor_key:
      default: nightly-report
    monitor_schedule:
      default: '17 3 * * *'
---
.cronradar:
  variables:
    PING_URL: https://cron.life/api/v1/ping/$[[ inputs.monitor_key ]]
    MONITOR_SCHEDULE: '$[[ inputs.monitor_schedule ]]'
    GRACE_SECONDS: '3600'
</code></pre>
<p>This replaces the <code>variables</code> of <code>.cronradar</code>; its <code>before_script</code> and the jobs stay as above. Then set <code>monitor_key</code> and <code>monitor_schedule</code> in each schedule's <strong>Inputs</strong>.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why is my GitLab scheduled pipeline not running?</h3>
<p>Open <strong>Build</strong> > <strong>Pipeline schedules</strong>. A schedule marked <code>Inactive</code> may have an owner who was blocked or removed from the project; a Maintainer or Owner can select <strong>Take ownership</strong>, then edit the schedule and select <strong>Activated</strong>, because taking ownership alone leaves it inactive. If the schedule is active, look for a <code>yaml invalid</code> pipeline on its target branch, <code>workflow:rules</code> or job <code>rules</code> that add no jobs when <code>CI_PIPELINE_SOURCE</code> is <code>schedule</code>, and jobs stuck <code>pending</code> without a runner or compute minutes. Schedules also stop when the project or group is marked for deletion.</p>
<h3>Why does my GitLab scheduled pipeline start late?</h3>
<p>GitLab queues due pipelines only when its pipeline schedule worker runs: every 5 minutes on GitLab.com, and every 10 minutes (<code>3-59/10 * * * *</code>) by default on self-managed instances. On GitLab.com Free, a schedule whose minute isn't a multiple of 5 waits for the next full hour. Under load, batching can add several minutes to over an hour, and the jobs then wait for a runner. Stagger schedules away from the top of the hour, in multiples of 5 minutes on Free, and measure the delay from each scheduled pipeline's <code>created_at</code>.</p>
<h3>Can a GitLab pipeline schedule run every minute?</h3>
<p>Not on GitLab.com. Its worker runs every 5 minutes, and Free namespaces are limited to 24 pipelines per schedule per day, which slows a schedule to once an hour at most; paid tiers allow 288, one every 5 minutes. On a self-managed instance, an administrator can run the worker every minute with <code>pipeline_schedule_worker_cron</code>. There's no daily limit by default; if one has been set, <code>ci_daily_pipeline_schedule_triggers</code> must be 1440 to allow a pipeline a minute.</p>
<h3>What time zone do GitLab pipeline schedules use?</h3>
<p>Each schedule has its own <code>cron_timezone</code>, which defaults to UTC when you create the schedule through the API. GitLab's documentation doesn't describe what happens in an hour that daylight saving time skips or repeats, so UTC is the predictable choice, and it matches what CronRadar expects.</p>
<h3>How do I run a job only in GitLab scheduled pipelines?</h3>
<p>Give the job <code>rules: - if: $CI_PIPELINE_SOURCE == "schedule"</code>, and use the same condition with <code>when: never</code> to keep other jobs out of scheduled pipelines. If the file has <code>workflow:rules</code>, one of them must also match schedules, or the scheduled pipeline doesn't run at all.</p>
<h3>How do I get alerted when a GitLab schedule doesn't run?</h3>
<p>GitLab's pipeline emails cover pipelines that exist, so a pipeline blocked by rules or a schedule someone deactivated sends nothing. When GitLab itself deactivates a schedule because its owner can no longer create pipelines, it emails the project's Owners and Maintainers. Add check-in jobs in the <code>.pre</code> and <code>.post</code> stages that report to an external monitor, which alerts when a check-in doesn't arrive within a grace period of the scheduled time. With CronRadar, create the monitor before the first run so even a schedule that never runs produces a never-executed alert, and a <code>fail</code> check-in alerts at once.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>gitlab scheduled pipeline</category>
      <category>gitlab pipeline schedule not running</category>
      <category>gitlab pipeline schedule inactive</category>
      <category>gitlab CI_PIPELINE_SOURCE schedule</category>
      <category>monitor gitlab scheduled pipelines</category>
    </item>
    <item>
      <title><![CDATA[Hangfire Dashboard Authorization: 401 in Production, Policies, and Read-Only]]></title>
      <link>https://cronradar.com/blog/hangfire-dashboard-authorization</link>
      <guid>https://cronradar.com/blog/hangfire-dashboard-authorization</guid>
      <description><![CDATA[Why the Hangfire 1.8 dashboard returns 401 off localhost, how to write IDashboardAuthorizationFilter or use a policy, and how to set read-only and proxy paths.]]></description>
      <content:encoded><![CDATA[<p><strong>The Hangfire dashboard returns 401 in production because its default authorization filter, <code>LocalRequestsOnlyAuthorizationFilter</code>, lets in only requests that come from the same machine.</strong> Replace it with your own <code>IDashboardAuthorizationFilter</code>, or map the dashboard with <code>MapHangfireDashboardWithAuthorizationPolicy</code> so an ASP.NET Core policy decides. Add <code>IsReadOnlyFunc</code> for people who should look but not delete or requeue.</p>
<p>This guide covers Hangfire.Core and Hangfire.AspNetCore 1.8.25, the current release on <a href="https://www.nuget.org/packages/Hangfire.AspNetCore/1.8.25">NuGet</a> (checked October 30, 2026). For reading job state in code instead of through the UI, see <a href="https://cronradar.com/blog/hangfire-monitoring">Hangfire monitoring</a>.</p>
<h2>Why the Dashboard Returns 401 Off Localhost</h2>
<p>A <code>new DashboardOptions()</code> sets <code>Authorization</code> to a single <code>LocalRequestsOnlyAuthorizationFilter</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs">DashboardOptions.cs</a>). <code>UseHangfireDashboard()</code> and <code>MapHangfireDashboard()</code> called without options use a <code>DashboardOptions</code> registered in DI if there is one, and that default otherwise (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireApplicationBuilderExtensions.cs">HangfireApplicationBuilderExtensions.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireEndpointRouteBuilderExtensions.cs">HangfireEndpointRouteBuilderExtensions.cs</a>). The <a href="https://docs.hangfire.io/en/latest/configuration/using-dashboard.html">dashboard documentation</a> states it directly: "By default Hangfire allows access to Dashboard pages only for local requests."</p>
<p>The filter reads <code>HttpContext.Connection.RemoteIpAddress</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardRequest.cs">AspNetCoreDashboardRequest.cs</a>) and allows the request only when (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/LocalRequestsOnlyAuthorizationFilter.cs">LocalRequestsOnlyAuthorizationFilter.cs</a>):</p>
<ul>
<li>the remote address is <code>127.0.0.1</code> or <code>::1</code>,</li>
<li>the remote address equals the local address of the connection, or</li>
<li>the remote address parses as a loopback address, such as <code>::ffff:127.0.0.1</code>.</li>
</ul>
<p>An empty remote address is treated as not local. So the dashboard works on your machine and fails as soon as the browser is somewhere else.</p>
<h3>401 or 403: what the status code tells you</h3>
<p>When any filter returns <code>false</code>, the dashboard middleware sets the status code and writes nothing else (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardMiddleware.cs">AspNetCoreDashboardMiddleware.cs</a>):</p>
<table>
<thead>
<tr>
<th>Response</th>
<th>Cause in Hangfire 1.8.25</th>
</tr>
</thead>
<tbody>
<tr>
<td>401, empty body</td>
<td>A filter refused and <code>HttpContext.User</code> is not authenticated</td>
</tr>
<tr>
<td>403, empty body</td>
<td>A filter refused and the user is authenticated</td>
</tr>
<tr>
<td>403 on a POST</td>
<td><code>IAntiforgery</code> is registered and the anti-forgery token is missing or invalid (unless <code>IgnoreAntiforgeryToken</code>)</td>
</tr>
<tr>
<td>401 on an action</td>
<td><code>IsReadOnlyFunc</code> returned <code>true</code> and someone tried to delete, requeue, or trigger something</td>
</tr>
<tr>
<td>404</td>
<td>The path under the dashboard prefix matches no dashboard route (endpoint-mapped dashboard only)</td>
</tr>
</tbody>
</table>
<p>The middleware does not call your authentication scheme's challenge, so a filter refusal never redirects to a login page. If you are signed in and a page load (not a button action) gets 401, Hangfire saw an unauthenticated <code>HttpContext.User</code>. A 401 on delete, requeue, or trigger means <code>IsReadOnlyFunc</code> returned <code>true</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/CommandDispatcher.cs">CommandDispatcher.cs</a>). Middleware runs in the order it is added (<a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/#middleware-order">Microsoft middleware order</a>), so call <code>UseAuthentication</code> before <code>UseHangfireDashboard</code>. Hangfire's "Method call order is important" note in the <a href="https://docs.hangfire.io/en/latest/configuration/using-dashboard.html">dashboard documentation</a> gives the same rule for OWIN Startup classes.</p>
<h2>Replace the Filter With IDashboardAuthorizationFilter</h2>
<p><code>IDashboardAuthorizationFilter</code> has one method, <code>bool Authorize(DashboardContext context)</code>, and <code>GetHttpContext()</code> from Hangfire.AspNetCore gives you the ASP.NET Core <code>HttpContext</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/Dashboard/AspNetCoreDashboardContextExtensions.cs">AspNetCoreDashboardContextExtensions.cs</a>):</p>
<pre><code class="language-csharp">using Hangfire.Dashboard;

public sealed class HangfireAdminFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var user = context.GetHttpContext().User;

        return user.Identity?.IsAuthenticated == true
            &#x26;&#x26; user.IsInRole("HangfireAdmin");
    }
}
</code></pre>
<p>Assigning <code>Authorization</code> replaces the default list, so the local-only filter is gone:</p>
<pre><code class="language-csharp">using Hangfire;

app.UseAuthentication();
app.UseAuthorization();

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireAdminFilter() }
});
</code></pre>
<p>When you pass several filters, every one must return <code>true</code> (<a href="https://docs.hangfire.io/en/latest/configuration/using-dashboard.html">documentation</a>). The middleware runs all synchronous filters first, then the async ones, and stops at the first refusal.</p>
<h3>The async variant</h3>
<p>Use <code>IDashboardAsyncAuthorizationFilter</code>, whose single method is <code>Task&#x3C;bool> AuthorizeAsync(DashboardContext context)</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/IDashboardAsyncAuthorizationFilter.cs">IDashboardAsyncAuthorizationFilter.cs</a>), when the check has to await something, such as a policy evaluation or a lookup. This one evaluates a named ASP.NET Core policy through <a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authorization.authorizationserviceextensions.authorizeasync"><code>IAuthorizationService.AuthorizeAsync</code></a>:</p>
<pre><code class="language-csharp">using Hangfire.Dashboard;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;

public sealed class HangfirePolicyFilter : IDashboardAsyncAuthorizationFilter
{
    public async Task&#x3C;bool> AuthorizeAsync(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();
        var authorization = httpContext.RequestServices
            .GetRequiredService&#x3C;IAuthorizationService>();

        var result = await authorization.AuthorizeAsync(httpContext.User, "HangfireViewer");
        return result.Succeeded;
    }
}
</code></pre>
<pre><code class="language-csharp">app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    AsyncAuthorization = new[] { new HangfirePolicyFilter() }
});
</code></pre>
<p>Setting <code>AsyncAuthorization</code> also clears <code>Authorization</code>, but only while <code>Authorization</code> still holds the built-in default (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs">DashboardOptions.cs</a>). If you set both, both lists run.</p>
<h2>Use an ASP.NET Core Authorization Policy</h2>
<p>With endpoint routing, Hangfire.AspNetCore has three mapping methods (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.AspNetCore/HangfireEndpointRouteBuilderExtensions.cs">HangfireEndpointRouteBuilderExtensions.cs</a>). <code>MapHangfireDashboard</code> has two overloads: one without a pattern, which maps <code>/hangfire</code>, and one whose <code>pattern</code> is required. The other two default <code>pattern</code> to <code>"/hangfire"</code>.</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Filters that run</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MapHangfireDashboard(options, storage)</code> or <code>MapHangfireDashboard(pattern, options, storage)</code></td>
<td>Whatever <code>options</code> holds, local-only by default</td>
</tr>
<tr>
<td><code>MapHangfireDashboardWithNoAuthorizationFilters(pattern = "/hangfire", options, storage)</code></td>
<td>None: it empties <code>Authorization</code> and <code>AsyncAuthorization</code></td>
</tr>
<tr>
<td><code>MapHangfireDashboardWithAuthorizationPolicy(policyName, pattern = "/hangfire", options, storage)</code></td>
<td>None, plus <code>.RequireAuthorization(policyName)</code> on the endpoint</td>
</tr>
</tbody>
</table>
<p>The policy method is the shortest route to a production setup:</p>
<pre><code class="language-csharp">using Hangfire;

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("HangfireViewer", policy => policy
        .RequireAuthenticatedUser()
        .RequireRole("HangfireAdmin", "Operations"));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer");
</code></pre>
<p>Two consequences follow from the source:</p>
<ul>
<li><strong>Filters you pass are discarded.</strong> <code>MapHangfireDashboardWithAuthorizationPolicy</code> and <code>MapHangfireDashboardWithNoAuthorizationFilters</code> overwrite <code>Authorization</code> and <code>AsyncAuthorization</code> on the options object you give them. Put every access rule in the policy.</li>
<li><strong><code>MapHangfireDashboard(...).RequireAuthorization(...)</code> keeps the local-only filter.</strong> Plain <code>MapHangfireDashboard</code> doesn't touch the filters, so a user who passes your policy from a remote browser still gets 401 or 403 from Hangfire. Use the policy method, or set <code>Authorization</code> yourself.</li>
</ul>
<p><code>MapHangfireDashboardWithNoAuthorizationFilters</code> with no <code>RequireAuthorization</code> after it leaves the dashboard open to anyone who can reach the URL.</p>
<h2>Make the Dashboard Read-Only for Some Users</h2>
<p><code>IsReadOnlyFunc</code> takes a <code>Func&#x3C;DashboardContext, bool></code> and defaults to <code>_ => false</code>, so everyone who gets in has full control (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs">DashboardOptions.cs</a>). Hangfire evaluates it once per request (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/DashboardContext.cs">DashboardContext.cs</a>). This gives the <code>HangfireViewer</code> policy access, and buttons only to <code>HangfireAdmin</code>:</p>
<pre><code class="language-csharp">using Hangfire;
using Hangfire.Dashboard;

app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", options: new DashboardOptions
{
    IsReadOnlyFunc = context =>
        !context.GetHttpContext().User.IsInRole("HangfireAdmin")
});
</code></pre>
<p>The documentation describes the read-only view as one that "prevents users from changing anything, such as deleting or enqueueing jobs." It is enforced on the server too: the command dispatchers behind those buttons return 401 when <code>IsReadOnly</code> is <code>true</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/CommandDispatcher.cs">CommandDispatcher.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/BatchCommandDispatcher.cs">BatchCommandDispatcher.cs</a>).</p>
<p>Read-only controls actions, not visibility. A read-only viewer still opens every page, and the <a href="https://docs.hangfire.io/en/latest/configuration/using-dashboard.html">dashboard documentation</a> warns that the dashboard "exposes sensitive information about your background jobs, including method names and serialized arguments." Decide who gets in with authorization. <code>DisplayStorageConnectionString</code> also defaults to <code>true</code>; set it to <code>false</code> if viewers shouldn't see which storage the dashboard reads.</p>
<h2>Behind a Reverse Proxy or Path Base</h2>
<h3>A same-host proxy makes every request look local</h3>
<p><code>LocalRequestsOnlyAuthorizationFilter</code> trusts the connection's remote address. When Nginx, Caddy, or another proxy on the same host forwards to Kestrel over <code>127.0.0.1</code> and nothing rewrites the address, the remote address is the proxy's loopback address, and the filter allows <strong>every</strong> visitor. When the proxy runs on another machine or container, the remote address is that proxy's, and the filter refuses everyone: 401 for anonymous visitors, 403 for signed-in users.</p>
<p>ASP.NET Core's Forwarded Headers Middleware can rewrite <code>HttpContext.Connection.RemoteIpAddress</code> from <code>X-Forwarded-For</code>, and by default it accepts those headers only from loopback proxies (<a href="https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer">Microsoft proxy documentation</a>); a proxy elsewhere has to be added to <code>KnownProxies</code> or <code>KnownNetworks</code>. Either way, an IP check is the wrong control for a production dashboard. Replace the default with a filter or policy that checks the signed-in user.</p>
<h3>Links break under a stripped prefix</h3>
<p>Every dashboard link is built as <code>PrefixPath + Request.PathBase + relativePath</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/UrlHelper.cs">UrlHelper.cs</a>). If the public URL is <code>https://example.com/ops/hangfire</code> and the proxy strips <code>/ops</code> before forwarding, the app never sees <code>/ops</code>, so the dashboard's CSS, scripts, and links point at <code>/hangfire/...</code> and fail. Two fixes, depending on what the proxy forwards:</p>
<ul>
<li><strong>The proxy strips the prefix:</strong> set <code>PrefixPath = "/ops"</code>. Its source comment describes the result as <code>{domain}/{PrefixPath}/{hangfire}</code>.</li>
<li><strong>The proxy keeps the prefix:</strong> call <code>app.UsePathBase("/ops")</code>, which moves <code>/ops</code> into <code>Request.PathBase</code>. With <code>WebApplication</code>, call <code>UseRouting</code> after <code>UsePathBase</code> so routing sees the rewritten path (<a href="https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer">Microsoft proxy documentation</a>).</li>
</ul>
<pre><code class="language-csharp">app.MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", options: new DashboardOptions
{
    // The proxy serves https://example.com/ops/hangfire and strips /ops.
    PrefixPath = "/ops",
    // The Back to site link; null hides it.
    AppPath = "/ops/"
});
</code></pre>
<p><code>AppPath</code> defaults to <code>/</code>. To move the dashboard itself, change the pattern: <code>UseHangfireDashboard("/jobs")</code> or <code>MapHangfireDashboardWithAuthorizationPolicy("HangfireViewer", "/jobs")</code>.</p>
<h2>What the Dashboard Cannot Do</h2>
<p>The dashboard reads storage and renders a page only when someone requests one. Its live counters update by polling <code>/stats</code> from an open page every <code>StatsPollingInterval</code>, 2,000 milliseconds by default, and no <code>DashboardOptions</code> setting sends a notification (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DashboardOptions.cs">DashboardOptions.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/DashboardRoutes.cs">DashboardRoutes.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Pages/LayoutPage.cshtml">LayoutPage.cshtml</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Content/js/hangfire.js">hangfire.js</a>). <a href="https://cronradar.com/blog/hangfire-monitoring">Hangfire monitoring</a> covers when to add a schedule-based alert, <a href="https://cronradar.com/blog/hangfire-recurring-job-not-running">Hangfire recurring job not running</a> covers runs that never start, <a href="https://cronradar.com/blog/hangfire-job-stuck-processing">Hangfire job stuck in processing</a> covers runs that start and never finish, and <a href="https://cronradar.com/blog/cron-dead-mans-switch">dead man's switch</a> explains why the check that a run happened belongs outside the process.</p>
<h2>Alert on Missed and Failed Runs With CronRadar</h2>
<p>CronRadar is a cron-only monitoring service. It knows each recurring job's schedule and alerts when a run fails or hasn't arrived by the end of its grace period, by email, Slack, webhook, Discord, or PagerDuty. The alert comes from CronRadar, not from the Hangfire host, so it still fires when that host is down.</p>
<p>Store the CronRadar application key as <code>CRONRADAR_API_KEY</code> in your environment or secret store, not in <code>appsettings.json</code> or any committed file. Then install the package in the host that runs the Hangfire server:</p>
<pre><code class="language-bash">dotnet add package CronRadar.Hangfire
</code></pre>
<pre><code class="language-csharp">using CronRadar.Hangfire;

// After your existing AddHangfire(...) and AddHangfireServer() calls.
// Reads the key from the CRONRADAR_API_KEY environment variable.
builder.Services.AddCronRadarHangfire();
</code></pre>
<p>In the published 0.1.0 (<a href="https://www.nuget.org/packages/CronRadar.Hangfire/0.1.0">NuGet</a>, checked October 30, 2026), that call adds an execution filter that reports each recurring job's start, completion, and failure, and a hosted service that declares every recurring job and its cron expression at startup. Monitoring errors are caught, so they can't fail a job. A recurring job with no start after its expected time goes from <code>warning</code> to <code>critical</code> once the grace period passes. To exclude a job, put <code>[SkipMonitor]</code> on its method.</p>
<p>Two things show up in the dashboard you just secured:</p>
<ul>
<li><strong>A <code>cronradar:reconcile</code> recurring job.</strong> The package registers it to re-declare the registry every 15 minutes by default (<code>reconcileIntervalMinutes</code>, clamped to 1 through 59). If an admin deletes it from the Recurring Jobs page, it is registered again the next time the host starts. A read-only viewer can't delete it.</li>
<li><strong>Early failure reports.</strong> CronRadar.Hangfire 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations, so a job that later succeeds on retry can still alert. Reporting only after retries are exhausted requires the pending 0.2.1 release.</li>
</ul>
<p>CronRadar evaluates schedules in UTC; for jobs with a non-UTC <code>RecurringJobOptions.TimeZone</code>, see <a href="https://cronradar.com/blog/hangfire-cron-time-zones">Hangfire cron expressions and time zones</a>. The <a href="https://docs.cronradar.com/integrations/hangfire">Hangfire integration guide</a> has the full setup and the current release notice. Keep the dashboard for retries, requeues, and debugging; CronRadar tells you when to open it.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why does the Hangfire dashboard work locally but return 401 in production?</h3>
<p>The default <code>DashboardOptions.Authorization</code> is <code>LocalRequestsOnlyAuthorizationFilter</code>, which allows only loopback addresses or a remote address equal to the local one. A browser on another machine fails it and, unauthenticated, gets 401. Replace the filter or use <code>MapHangfireDashboardWithAuthorizationPolicy</code>.</p>
<h3>Why do I get 403 instead of 401?</h3>
<p>Hangfire returns 403 when a filter refuses a user who is already authenticated, and 401 when the user isn't. A 403 on a button click can also be a failed anti-forgery check.</p>
<h3>Can I use RequireAuthorization with MapHangfireDashboard?</h3>
<p>You can, but <code>MapHangfireDashboard</code> keeps the local-only default filter, so remote users are still refused. <code>MapHangfireDashboardWithAuthorizationPolicy</code> removes the filters and applies <code>RequireAuthorization</code> for you.</p>
<h3>Does read-only mode hide job arguments?</h3>
<p>No. <code>IsReadOnlyFunc</code> blocks deleting, requeuing, and triggering, and the server rejects those commands with 401. Every page stays visible, so restrict who gets in with authorization.</p>
<h3>Is it safe to expose the dashboard behind Nginx on the same server?</h3>
<p>Not with the default filter. Unless forwarded headers rewrite the remote address, the proxy connects from <code>127.0.0.1</code>, so <code>LocalRequestsOnlyAuthorizationFilter</code> lets every visitor in. Use a filter or policy that checks the signed-in user.</p>
<h3>Can the Hangfire dashboard send alerts?</h3>
<p>No. It renders pages on request and has no notification setting. Use an external check, such as CronRadar, to be told when a recurring job fails or doesn't run.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>hangfire dashboard authorization</category>
      <category>hangfire dashboard 401 unauthorized</category>
      <category>hangfire dashboard read only</category>
      <category>hangfire dashboard authorization policy</category>
      <category>hangfire dashboard reverse proxy</category>
    </item>
    <item>
      <title><![CDATA[Hangfire Dependency Injection: Scoped Services, DbContext, and Disposed Objects]]></title>
      <link>https://cronradar.com/blog/hangfire-dependency-injection</link>
      <guid>https://cronradar.com/blog/hangfire-dependency-injection</guid>
      <description><![CDATA[How Hangfire.AspNetCore 1.8 gives each job its own DI scope, why jobs throw "Cannot access a disposed object," and safe DbContext patterns for recurring jobs.]]></description>
      <content:encoded><![CDATA[<p><strong>With Hangfire.AspNetCore, every background job runs in its own dependency injection scope. The worker creates the scope, resolves the job class from it, runs the method, and disposes the scope once the method (or the <code>Task</code> it returns) finishes, so a scoped <code>DbContext</code> injected into the job's constructor is safe.</strong> "Cannot access a disposed object" means the job reached a service from some other scope, usually an HTTP request's, or kept using its own scope's services after the method returned.</p>
<p>This guide covers Hangfire.AspNetCore 1.8.25, the current release on <a href="https://www.nuget.org/packages/Hangfire.AspNetCore/1.8.25">NuGet</a> (checked October 30, 2026), with Hangfire.Core of the same version. .NET and EF Core source links point to the v9.0.22 tags, the latest .NET 9 patch in both repositories on the same date. Retry counts and delays are covered in <a href="https://cronradar.com/blog/hangfire-automatic-retry">Hangfire automatic retry</a>, jobs that never start in <a href="https://cronradar.com/blog/hangfire-recurring-job-not-running">why a Hangfire recurring job is not running</a>, and scoped services in a plain hosted service in <a href="https://cronradar.com/blog/dotnet-backgroundservice-scheduled-tasks">.NET BackgroundService scheduled tasks</a>.</p>
<h2>How Hangfire.AspNetCore Activates a Job</h2>
<p><code>AddHangfire</code> builds Hangfire's global configuration from the container. Before it runs your configuration callback, it installs an <code>AspNetCoreJobActivator</code> built from the container's <code>IServiceScopeFactory</code>, so a <code>UseActivator</code> call inside your callback replaces it (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/HangfireServiceCollectionExtensions.cs#L82-L103">HangfireServiceCollectionExtensions.cs</a>). <code>AddHangfireServer</code> hands that activator to the server unless <code>BackgroundJobServerOptions.Activator</code> is already set (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/HangfireServiceCollectionExtensions.cs#L243-L248">same file</a>). The activator lives in the Hangfire.NetCore package, which Hangfire.AspNetCore references.</p>
<p>What happens for each run, from the source:</p>
<ol>
<li><strong>A new scope per execution.</strong> <code>CoreBackgroundJobPerformer.Perform</code> opens <code>using (var scope = _activator.BeginScope(context))</code> around the whole run (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L47-L73">CoreBackgroundJobPerformer.cs</a>), and <code>BeginScope</code> returns a wrapper around <code>_serviceScopeFactory.CreateScope()</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivator.cs#L32-L42">AspNetCoreJobActivator.cs</a>).</li>
<li><strong>The job type is resolved from that scope.</strong> <code>Resolve</code> calls <code>ActivatorUtilities.GetServiceOrCreateInstance(_serviceScope.ServiceProvider, type)</code> (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivatorScope.cs#L32-L35">AspNetCoreJobActivatorScope.cs</a>). That method returns the registered service, and <a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.activatorutilities.getserviceorcreateinstance">"if one is not found then instantiate it directly"</a>, filling the constructor from the same scope. A job class doesn't have to be registered.</li>
<li><strong>Static methods get no instance.</strong> <code>Perform</code> calls <code>Resolve</code> only when the job method isn't static. A static job method gets nothing from the container.</li>
<li><strong>Async jobs keep the scope until their task completes.</strong> For a method that returns <code>Task</code>, <code>InvokeMethod</code> blocks until the task finishes before <code>Perform</code> returns (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L154-L215">CoreBackgroundJobPerformer.cs</a>).</li>
<li><strong>The scope is then disposed.</strong> <code>DisposeScope</code> calls <code>DisposeAsync</code> when the scope supports it and <code>Dispose</code> otherwise (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/AspNetCore/AspNetCoreJobActivatorScope.cs#L37-L52">AspNetCoreJobActivatorScope.cs</a>), which disposes every scoped and transient disposable resolved during the run, including the <code>DbContext</code>.</li>
</ol>
<p>So the lifetimes you registered behave like this inside a job:</p>
<table>
<thead>
<tr>
<th>Registered as</th>
<th>Inside a Hangfire job</th>
</tr>
</thead>
<tbody>
<tr>
<td>Scoped (<code>AddDbContext</code> uses this)</td>
<td>One instance per run, shared by everything resolved in that run, disposed when the run ends</td>
</tr>
<tr>
<td>Transient</td>
<td>A new instance per resolution; disposable ones are disposed with the run's scope</td>
</tr>
<tr>
<td>Singleton</td>
<td>One instance for the whole process, shared by every worker running at the same time</td>
</tr>
<tr>
<td>Not registered (the job class, commonly)</td>
<td>Constructed by <code>ActivatorUtilities</code> for each run, with constructor arguments from the run's scope</td>
</tr>
</tbody>
</table>
<p>Each retry is another call to <code>Perform</code>, so it gets a new scope too. So does each occurrence of a recurring job, because each occurrence is an ordinary background job.</p>
<h3>The Expression Stores a Type, Not Your Object</h3>
<p>A common worry is that <code>BackgroundJob.Enqueue(() => _mailer.SendAsync(id))</code> inside a controller drags the request's <code>_mailer</code> into the job. It doesn't. When the expression's target is a variable or field, Hangfire evaluates it only to read its runtime type and stores that type; a comment in the source says, "People consider that the whole object is serialized, this is not true" (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L420-L462">Job.cs</a>). At run time the worker resolves that type from the job's own scope.</p>
<p>The catch is which type gets stored: the concrete class behind <code>_mailer</code>, such as <code>SmtpInvoiceMailer</code>, not <code>IInvoiceMailer</code>. If only the interface is registered, the activator falls back to constructing the concrete class with <code>ActivatorUtilities</code>, not through your registration. Name the type explicitly and Hangfire stores that type instead:</p>
<pre><code class="language-csharp">// Stores IInvoiceMailer; the worker resolves it from the job's scope.
BackgroundJob.Enqueue&#x3C;IInvoiceMailer>(mailer => mailer.SendAsync(invoiceId));
</code></pre>
<h2>What "Cannot Access a Disposed Object" Means in a Job</h2>
<p>Two messages show up, and they point at different things:</p>
<ul>
<li><strong><code>System.ObjectDisposedException: Cannot access a disposed object. Object name: 'IServiceProvider'.</code></strong> Something resolved a service from a scope that had already been disposed. The container's scope checks for disposal on every <code>GetService</code> call and throws with the object name <code>IServiceProvider</code> (<a href="https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngineScope.cs#L43-L48">ServiceProviderEngineScope.cs</a>, <a href="https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ThrowHelper.cs">ThrowHelper.cs</a>, <a href="https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx#L3173-L3178">message text</a>).</li>
<li><strong><code>System.ObjectDisposedException: Cannot access a disposed context instance.</code></strong> Something used a <code>DbContext</code> after it was disposed, either by its scope or by your own code. EF Core throws this from <code>CheckDisposed</code> with your context's class name as the object name (<a href="https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/DbContext.cs#L488-L496">DbContext.cs</a>, <a href="https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Properties/CoreStrings.resx#L369-L371">CoreStrings.resx</a>).</li>
</ul>
<p>Because the job's own scope is always fresh, both mean the code reached outside it or outlived it. These are the causes, in the order they're usually found.</p>
<h3>A Provider Captured From an HTTP Request</h3>
<p>ASP.NET Core creates a scope per request and exposes it as <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-9.0"><code>HttpContext.RequestServices</code></a>, and <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes">"in apps that process requests, scoped services are disposed at the end of the request"</a>. A job runs on a Hangfire worker after the request has returned, so anything it reaches through the request's provider is already gone.</p>
<p>Static job methods are where this usually starts. Hangfire's <a href="https://docs.hangfire.io/en/latest/background-methods/passing-dependencies.html">Passing Dependencies</a> page notes that a static method is restricted to your application's static context: <code>new</code>, service location, factories, or singletons. A static <code>IServiceProvider</code> assigned from a request is the version that breaks:</p>
<pre><code class="language-csharp">// Broken: Services holds the provider of the request that last set it.
public static class ReportRunner
{
    public static IServiceProvider? Services;

    public static void Run()
    {
        var db = Services!.GetRequiredService&#x3C;AppDbContext>(); // throws once that request has ended
        // ...
    }
}

// In a controller action:
ReportRunner.Services = HttpContext.RequestServices;
BackgroundJob.Enqueue(() => ReportRunner.Run());
</code></pre>
<p>The same failure comes from a singleton that caches a scoped service or a provider it was handed during a request. The fix is to make the job an instance method on a class that takes its dependencies in its constructor, as shown in <a href="#inject-scoped-services-into-the-job-class">the pattern below</a>.</p>
<h3>A Service Passed as a Job Argument</h3>
<p>Arguments are not handed to the job in memory. Hangfire evaluates each argument expression when you enqueue (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L540-L560">Job.cs</a>) and <a href="https://docs.hangfire.io/en/latest/background-methods/passing-arguments.html">serializes it to JSON with Newtonsoft.Json</a> for storage, so <code>job => job.Run(_db)</code> never gives the job the live context you passed. Hangfire rejects delegate and expression parameters outright ("Anonymous functions, delegates and lambda expressions aren't supported in job method parameters", <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L530-L535">Job.cs</a>), so a <code>Func&#x3C;AppDbContext></code> factory can't be passed either.</p>
<p>Pass identifiers and plain values, as Hangfire's docs recommend: "store concrete values in an application database and pass their identifiers only to your background jobs." Let the job resolve its services from its own scope.</p>
<p>Three parameter types are filled in by the server instead: <code>CancellationToken</code>, <code>IJobCancellationToken</code>, and <code>PerformContext</code>. Whatever value was stored for them, Hangfire substitutes its own when the job runs (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L30-L36">CoreBackgroundJobPerformer.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L257-L279">SubstituteArguments</a>). The <code>CancellationToken</code> it passes is linked to both the server's shutdown and the job's abort (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/ServerJobCancellationToken.cs#L223-L234">ServerJobCancellationToken.cs</a>), and Hangfire's <a href="https://docs.hangfire.io/en/latest/background-methods/using-cancellation-tokens.html">Using Cancellation Tokens</a> page says that "when state change is detected or when shutdown is requested, the corresponding cancellation token is canceled." The same page writes <code>CancellationToken.None</code> in the job expression, since "any CancellationToken instance can be used, it will be replaced internally just before performing a background job."</p>
<h3>Work That Outlives the Job Method</h3>
<p>The scope lasts exactly as long as the job method and the task it returns. Work the method starts but doesn't await keeps going after <code>Perform</code> has disposed the scope.</p>
<p>Hangfire blocks the obvious case: a job method declared <code>async void</code> is rejected when the job is created, with <code>NotSupportedException</code> "Async void methods are not supported. Use async Task instead." (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Common/Job.cs#L497-L501">Job.cs</a>). It can't see async void code inside the method:</p>
<pre><code class="language-csharp">// Broken: List&#x3C;T>.ForEach takes an Action&#x3C;T>, so this lambda is async void.
// The job method can return before the sends finish, and the scope is disposed under them.
overdue.ForEach(async invoice => await mailer.SendAsync(invoice.Id));

// Also broken: nothing awaits these, so they can run after the scope is gone.
_ = auditLog.WriteAsync(runId);
Task.Run(() => db.SaveChangesAsync());
</code></pre>
<p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1.foreach"><code>List&#x3C;T>.ForEach</code></a> takes an <code>Action&#x3C;T></code>, and Microsoft's docs warn that <a href="https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types">"an async method that returns void can't be awaited"</a> and that its caller can't catch exceptions it throws. Use a <code>foreach</code> loop with <code>await</code>, and await every task the job starts.</p>
<p>This case behaves differently from the others, and the two kinds of unawaited work fail differently:</p>
<ul>
<li><strong>A discarded task</strong>, like the <code>WriteAsync</code> and <code>Task.Run</code> calls above. When a method that returns a task throws, <a href="https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types">"the exception is stored in the returned task"</a> and rethrown only when that task is awaited. Nothing awaits it, so the job method returns normally and Hangfire records the run as Succeeded: a Failed state is built only when <code>Perform</code> throws (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L336-L352">Worker.cs</a>). Nothing retries, nothing reaches the Failed page, and nothing records the exception unless your code logs it.</li>
<li><strong>An <code>async void</code> lambda</strong>, like the one passed to <code>ForEach</code>. It has no task to hold an exception. .NET posts the exception to the <code>SynchronizationContext</code> that was current when the lambda started or, if there was none, throws it on the thread pool, which the runtime's source notes "will result in a crash" (<a href="https://github.com/dotnet/runtime/blob/v9.0.22/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncVoidMethodBuilder.cs#L123-L142">AsyncVoidMethodBuilder.cs</a>). Microsoft's docs say such exceptions <a href="https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types">"are likely to cause your application to fail"</a>, and an unhandled exception in a thread <a href="https://learn.microsoft.com/en-us/dotnet/standard/threading/exceptions-in-managed-threads">causes the application to terminate in most cases</a>. An <code>ObjectDisposedException</code> from a send that outlived the scope can take down the process hosting the Hangfire server, not only this job.</li>
</ul>
<h3>Disposing the Injected Context Yourself</h3>
<p>Wrapping an injected <code>DbContext</code> in <code>using</code> disposes it while the scope still hands the same instance to everything else in that run. EF Core's error text names this cause: "disposing a context instance that was resolved from dependency injection and then later trying to use the same context instance elsewhere." Microsoft's rule is that <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes">"services should never be disposed by code that resolved the service from the container"</a>. Remove the <code>using</code> and let the job's scope dispose it.</p>
<h3>A Related Error: A Second Operation on This Context</h3>
<p>If the job class or one of its dependencies is registered as a singleton and takes a <code>DbContext</code>, the context is captured: resolving a scoped service from a singleton <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes">"causes the scoped service to behave like a singleton"</a>. Every run then uses that one context, including runs on other workers at the same moment. The usual symptom isn't a disposed object but EF Core's "A second operation was started on this context instance before a previous operation completed" (<a href="https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Properties/CoreStrings.resx#L336-L338">CoreStrings.resx</a>), because a <a href="https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/"><code>DbContext</code> is not thread-safe</a>. In the Development environment you get an earlier error instead: there, by default, <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-lifetimes">"resolving a service from another service with a longer lifetime throws an exception"</a> (<a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/overview#scope-validation">scope validation</a>), so the shared-context error shows up where that check isn't on. <code>Task.WhenAll</code> over several queries on one injected context fails the same way. Register job classes as scoped or transient, or leave them unregistered.</p>
<h2>Why the Same Exception Comes Back on Every Run</h2>
<p>A fresh scope per run doesn't help when the bug is in the wiring. The static field in the example above still points at the disposed provider on the next attempt, and on the attempt after that, until the process restarts or another request overwrites it. Nothing changes between attempts, so the retries can't succeed:</p>
<ol>
<li>The first attempt throws <code>ObjectDisposedException</code>. Hangfire wraps it in <code>JobPerformanceException</code> ("An exception occurred during performance of the job.") on the way out of the performer (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/CoreBackgroundJobPerformer.cs#L76-L108">CoreBackgroundJobPerformer.cs</a>).</li>
<li>The global <code>AutomaticRetry</code> filter turns that failure into a scheduled retry, 10 times by default, with delays that add up to more than four hours. The schedule is in <a href="https://cronradar.com/blog/hangfire-automatic-retry">Hangfire automatic retry</a>.</li>
<li>Every retry fails the same way. After the last one, the worker unwraps the wrapper and stores a Failed state whose exception is the original <code>ObjectDisposedException</code> and whose reason is the wrapper's message (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L336-L352">Worker.cs</a>).</li>
<li>The job stays on the dashboard's Failed page, which shows the stored exception type, message, and details with the stack trace (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/States/FailedState.cs#L195-L203">FailedState.cs</a>, <a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Pages/FailedJobsPage.cshtml#L137-L145">FailedJobsPage.cshtml</a>). Hangfire's docs say a failed job <a href="https://docs.hangfire.io/en/latest/background-processing/dealing-with-exceptions.html">"will not be expired unless you delete it explicitly"</a>.</li>
</ol>
<p>A recurring job repeats this at every occurrence, each with its own retry chain; <a href="https://cronradar.com/blog/hangfire-automatic-retry#each-occurrence-gets-its-own-retry-budget">Hangfire automatic retry</a> covers how those chains overlap. Requeueing one from the dashboard doesn't help until the code is fixed and deployed.</p>
<p>Unawaited work doesn't follow this pattern, as <a href="#work-that-outlives-the-job-method">described above</a>: a discarded task leaves the run Succeeded, with nothing retried and nothing on the Failed page, and an exception from an <code>async void</code> lambda isn't stored in any task and can crash the process.</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Most likely cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Every attempt fails with <code>Object name: 'IServiceProvider'</code></td>
<td>The job resolves services from a provider captured during a request</td>
<td>Instance method with constructor injection</td>
</tr>
<tr>
<td>Every attempt fails with "Cannot access a disposed context instance"</td>
<td>Your code disposes the injected context, or uses one from another scope</td>
<td>Remove the <code>using</code>; let the scope dispose it</td>
</tr>
<tr>
<td>The job shows Succeeded but the work is missing, and no exception was recorded</td>
<td>A discarded or <code>Task.Run</code> task failed after the scope was disposed</td>
<td>Await every task the job starts</td>
</tr>
<tr>
<td>The process hosting the Hangfire server crashes</td>
<td>An <code>async void</code> lambda, such as one passed to <code>ForEach</code>, threw</td>
<td>A <code>foreach</code> loop with <code>await</code> instead of <code>ForEach</code></td>
</tr>
<tr>
<td>"A second operation was started on this context instance"</td>
<td>A singleton or parallel queries share one context</td>
<td>Scoped or unregistered job class; a factory for parallel work</td>
</tr>
</tbody>
</table>
<h2>Patterns That Work</h2>
<h3>Inject Scoped Services Into the Job Class</h3>
<p>Put the work in an instance method, take the <code>DbContext</code> and other services in the constructor, and register the recurring job with the generic <code>AddOrUpdate&#x3C;T></code> so Hangfire stores the job type:</p>
<pre><code class="language-csharp">using Hangfire;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext&#x3C;AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("App")));

builder.Services.AddHangfire(config => config
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();

var app = builder.Build();

app.Services.GetRequiredService&#x3C;IRecurringJobManager>().AddOrUpdate&#x3C;InvoiceReminderJob>(
    "invoice-reminders",
    job => job.RunAsync(CancellationToken.None), // replaced at run time; canceled on shutdown or a job state change
    "0 9 * * *");

app.Run();

public sealed class InvoiceReminderJob(AppDbContext db, ILogger&#x3C;InvoiceReminderJob> logger)
{
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var overdue = await db.Invoices
            .Where(invoice => invoice.DueAtUtc &#x3C; DateTime.UtcNow &#x26;&#x26; !invoice.ReminderSent)
            .ToListAsync(cancellationToken);

        foreach (var invoice in overdue)
        {
            invoice.ReminderSent = true;
        }

        await db.SaveChangesAsync(cancellationToken);
        logger.LogInformation("Marked {Count} overdue invoices for reminders.", overdue.Count);
    }
}
</code></pre>
<p><code>AppDbContext</code> stands for your EF Core context with an <code>Invoices</code> set. <code>InvoiceReminderJob</code> isn't registered, so the activator constructs it for each run and gives it that run's <code>AppDbContext</code>. The method returns a <code>Task</code>, so the scope stays open until <code>SaveChangesAsync</code> completes. The schedule is evaluated in UTC; <a href="https://cronradar.com/blog/hangfire-cron-time-zones">Hangfire cron expressions and time zones</a> covers the alternatives. The packages are the same ones that post installs, plus your EF Core provider.</p>
<h3>Use a DbContext Factory for Parallel or Batched Work</h3>
<p>When one run needs several contexts at once, or a fresh change tracker per batch, inject a factory instead of a context. <a href="https://learn.microsoft.com/en-us/ef/core/dbcontext-configuration/"><code>AddDbContextFactory</code></a> registers <code>IDbContextFactory&#x3C;T></code>, and each context you create from it is yours to dispose:</p>
<pre><code class="language-csharp">builder.Services.AddDbContextFactory&#x3C;AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("App")));
</code></pre>
<pre><code class="language-csharp">public sealed class RegionalTotalsJob(IDbContextFactory&#x3C;AppDbContext> contexts)
{
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        string[] regions = ["eu", "us", "apac"];

        await Task.WhenAll(regions.Select(async region =>
        {
            await using var db = contexts.CreateDbContext(); // one context per parallel branch
            var total = await db.Orders
                .Where(order => order.Region == region)
                .SumAsync(order => order.Amount, cancellationToken);
            // ... store total
        }));
    }
}
</code></pre>
<p>Here the job owns each context, so disposing it with <code>await using</code> is correct, unlike an injected one. <code>AddDbContextFactory</code> also registers the context type itself as a scoped service, as its source comment and registration code show (<a href="https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Extensions/EntityFrameworkServiceCollectionExtensions.cs#L636-L638">EntityFrameworkServiceCollectionExtensions.cs</a>, <a href="https://github.com/dotnet/efcore/blob/v9.0.22/src/EFCore/Extensions/EntityFrameworkServiceCollectionExtensions.cs#L919-L925">registration</a>), so jobs that take <code>AppDbContext</code> directly keep working.</p>
<h3>Keep Arguments Small and Re-read State in the Job</h3>
<p>Enqueue with IDs, load current rows inside the job, and design the work so that running it twice is harmless, since retries and restarts repeat it. <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">Idempotent scheduled jobs</a> covers that design, and <a href="https://cronradar.com/blog/hangfire-automatic-retry">Hangfire automatic retry</a> covers which exceptions are worth retrying at all. <code>ObjectDisposedException</code> from wiring never is: if one keeps a long retry chain alive, <code>[AutomaticRetry(Attempts = 0)]</code> on that job sends it to Failed on the first attempt while you fix the code.</p>
<h2>See the Failure From Outside Hangfire With CronRadar</h2>
<p>The dashboard's Failed page shows the exception, but only to someone who opens it, and a run that never starts leaves nothing there. CronRadar watches each recurring job's schedule from outside the process. The <code>CronRadar.Hangfire</code> package discovers recurring jobs and reports their runs:</p>
<pre><code class="language-bash">dotnet add package CronRadar.Hangfire
</code></pre>
<pre><code class="language-csharp">using CronRadar.Hangfire;

// Alongside builder.Services.AddHangfire(...) and AddHangfireServer().
builder.Services.AddCronRadarHangfire();
</code></pre>
<p>Set <code>CRONRADAR_API_KEY</code> in the host's environment or secret store rather than in a committed file. <strong>Package status, checked October 30, 2026:</strong> NuGet installs <code>CronRadar.Hangfire</code> 0.1.0, which can report a failure before Hangfire finishes retrying, including cancellations; reporting only after retries are exhausted requires the pending 0.2.1 release. In 0.1.0, from its <a href="https://github.com/cronradar/cronradar-hangfire/blob/v0.1.0/src/CronRadar.Hangfire/CronRadarServiceCollectionExtensions.cs">source at the release tag</a>:</p>
<ul>
<li><code>AddCronRadarHangfire()</code> adds a global Hangfire server filter, runs a registry reconcile at startup, and registers a recurring job, <code>cronradar:reconcile</code>, that repeats it every 15 minutes by default. That job's class isn't registered in DI, so <code>AspNetCoreJobActivator</code> constructs it through the same <code>GetServiceOrCreateInstance</code> fallback described above.</li>
<li>For each run of a recurring job, the <a href="https://github.com/cronradar/cronradar-hangfire/blob/v0.1.0/src/CronRadar.Hangfire/CronRadarExtensions.cs">filter</a> reports <code>start</code> before the method runs. Afterwards it reports <code>complete</code> if the run threw nothing and wasn't canceled, and <code>fail</code> otherwise. One-time background jobs are skipped, and <code>[SkipMonitor]</code> on a method opts it out.</li>
</ul>
<p>For the errors in this guide, that means:</p>
<ul>
<li><strong>A recurring job's wiring error alerts on the first attempt.</strong> Because 0.1.0 reports every failed attempt of a recurring job, the first <code>ObjectDisposedException</code> is a <code>fail</code>, and CronRadar records <code>failed</code> and opens an incident immediately (<a href="https://docs.cronradar.com/concepts">concepts</a>), hours before Hangfire's retry chain ends. Each later retry reports <code>fail</code> again. One-time jobs, such as the static <code>BackgroundJob.Enqueue</code> example above, aren't reported at all.</li>
<li><strong>The message is Hangfire's wrapper.</strong> The filter sends the message of the exception Hangfire gives server filters, which is the <code>JobPerformanceException</code> ("An exception occurred during performance of the job.") rather than the <code>ObjectDisposedException</code> inside it (<a href="https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/BackgroundJobPerformer.cs#L160-L181">BackgroundJobPerformer.cs</a>). Open the job on Hangfire's Failed page for the real exception.</li>
<li><strong>A discarded task reports success.</strong> The method returned normally, so 0.1.0 reports <code>complete</code>. 0.1.0 has no way to report outcome values, so CronRadar can't tell that the run skipped its work.</li>
<li><strong>A run that never happens still alerts.</strong> If no run reports by the expected time, the monitor goes from <code>healthy</code> to <code>warning</code> during the grace period and to <code>critical</code> after it, for example because the Hangfire server is down.</li>
</ul>
<p>Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Jobs with noisy transient failures will alert on attempts that later succeed until 0.2.1 ships; <a href="https://cronradar.com/blog/hangfire-automatic-retry#monitor-hangfire-retries-and-missed-runs-with-cronradar">Hangfire automatic retry</a> covers how retry settings change that, and <a href="https://cronradar.com/blog/hangfire-monitoring">Hangfire monitoring</a> covers what to check inside Hangfire itself.</p>
<h2>Frequently Asked Questions</h2>
<h3>Does Hangfire create a new DbContext for each job?</h3>
<p>Yes, if the context is registered as scoped, which <code>AddDbContext</code> does by default. Hangfire.AspNetCore creates a DI scope for every execution, resolves the job class from it, and disposes it when the method or its returned task finishes. Each retry and each recurring occurrence gets its own scope and its own context.</p>
<h3>Why does my Hangfire job throw "Cannot access a disposed object"?</h3>
<p>The job reached a service that belongs to a scope that was already disposed. Usually that is a provider or service captured during an HTTP request and reached later from a static method or a singleton. Otherwise, the job disposed its own injected <code>DbContext</code>, or started work it didn't await that ran after the job's scope was disposed. Hangfire's own scope is always fresh, so look for code that reaches outside it or outlives it.</p>
<h3>Can I inject a scoped service into a Hangfire job?</h3>
<p>Yes. Take it in the job class's constructor and enqueue or register the job with the generic overload, such as <code>AddOrUpdate&#x3C;InvoiceReminderJob>(...)</code>. The class doesn't need to be registered; if it is, register it as scoped or transient, because a singleton job keeps one context for the life of the process.</p>
<h3>Can I pass a DbContext or another service as a Hangfire job argument?</h3>
<p>No. Arguments are serialized to JSON when the job is created, so the job never receives the live object, and delegate parameters such as a factory function are rejected. Pass IDs and plain values, and let the job resolve services from its own scope.</p>
<h3>Why does the error happen on every retry?</h3>
<p>Each retry gets a new scope, but the cause is in the code: a static field still points at the same disposed provider, or the same disposing code runs again. All 10 default retries fail the same way, and the job ends on the Failed page with the original <code>ObjectDisposedException</code>. Fix the code and deploy; requeueing won't help.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>hangfire dependency injection</category>
      <category>hangfire cannot access a disposed object</category>
      <category>hangfire dbcontext</category>
      <category>hangfire scoped service</category>
      <category>AspNetCoreJobActivator</category>
    </item>
    <item>
      <title><![CDATA[NestJS Scheduled Tasks: @Cron, Errors, and Multiple Instances]]></title>
      <link>https://cronradar.com/blog/nestjs-scheduled-tasks</link>
      <guid>https://cronradar.com/blog/nestjs-scheduled-tasks</guid>
      <description><![CDATA[How @nestjs/schedule 12 runs @Cron, @Interval, and @Timeout, what happens to thrown errors, why every replica fires each job, and how to run it on one instance.]]></description>
      <content:encoded><![CDATA[<p><strong><code>@nestjs/schedule</code> runs <code>@Cron()</code>, <code>@Interval()</code>, and <code>@Timeout()</code> methods inside your application process, and it wraps each one in a try/catch that logs the error and keeps the schedule going. Every running instance of the app fires every job. To run a job once per tick across replicas, the NestJS docs point to <code>@OnOneInstance()</code> from <code>@nestjs/locks</code>.</strong></p>
<p>This guide covers @nestjs/schedule 12.0.2, the current release on <a href="https://www.npmjs.com/package/@nestjs/schedule/v/12.0.2">npm</a> (checked October 30, 2026), which depends on cron 4.4.0 (<a href="https://github.com/nestjs/schedule/blob/12.0.2/package.json">package.json</a>). The node-cron and node-schedule packages are covered in <a href="https://cronradar.com/blog/node-js-cron-monitoring">Node.js cron monitoring</a>. Queue-backed repeatable jobs are in <a href="https://cronradar.com/blog/bullmq-repeatable-jobs">BullMQ repeatable jobs</a>.</p>
<h2>Registering the Scheduler</h2>
<p>Import <code>ScheduleModule.forRoot()</code> in the root module. The <a href="https://docs.nestjs.com/application/task-scheduling">task scheduling docs</a> say to call <code>forRoot()</code> in one module only: each additional import registers every <code>@Cron()</code>, <code>@Interval()</code>, and <code>@Timeout()</code> handler again, so each job runs once per import.</p>
<pre><code class="language-typescript">import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'

@Module({
  imports: [ScheduleModule.forRoot()],
})
export class AppModule {}
</code></pre>
<p><code>forRoot()</code> accepts three booleans, <code>cronJobs</code>, <code>intervals</code>, and <code>timeouts</code>, all <code>true</code> by default (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.module.ts">schedule.module.ts</a>, <a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/interfaces/schedule-module-options.interface.ts">schedule-module-options.interface.ts</a>). With one set to <code>false</code>, the explorer skips that kind of handler (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.explorer.ts">schedule.explorer.ts</a>). The scheduler mounts timeouts, intervals, and cron jobs in <code>onApplicationBootstrap</code> and clears them in <code>beforeApplicationShutdown</code> (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.orchestrator.ts">scheduler.orchestrator.ts</a>).</p>
<h2>@Cron, @Interval, and @Timeout</h2>
<pre><code class="language-typescript">import { Injectable, Logger } from '@nestjs/common'
import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule'

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name)

  @Cron(CronExpression.EVERY_DAY_AT_2AM, {
    name: 'nightly-report',
    timeZone: 'Europe/Paris',
    waitForCompletion: true,
  })
  async nightlyReport() {
    this.logger.log('Building the report')
  }

  @Interval('cache-refresh', 10000)
  refreshCache() {}

  @Timeout(5000)
  warmUp() {}
}
</code></pre>
<ul>
<li><strong><code>@Cron(cronTime, options)</code></strong> takes a cron string, a JavaScript <code>Date</code>, or a Luxon <code>DateTime</code> (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/cron.decorator.ts">cron.decorator.ts</a>). A <code>Date</code> runs the method once at that moment. Cron strings have five fields, or six with seconds first (<a href="https://docs.nestjs.com/application/task-scheduling">docs</a>).</li>
<li><strong><code>@Interval(name?, ms)</code></strong> calls <code>setInterval()</code>, and <strong><code>@Timeout(name?, ms)</code></strong> calls <code>setTimeout()</code> once, counted from application startup. The name is optional and only needed to reach the timer through <code>SchedulerRegistry</code>.</li>
</ul>
<p>The <code>CronOptions</code> type at 12.0.2 defines these options (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/cron.decorator.ts">cron.decorator.ts</a>):</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>Registers the job under this name in <code>SchedulerRegistry</code>. Without it, the job gets a random UUID.</td>
</tr>
<tr>
<td><code>timeZone</code></td>
<td>Evaluates the expression in this time zone. An invalid zone throws.</td>
</tr>
<tr>
<td><code>utcOffset</code></td>
<td>A fixed offset instead of <code>timeZone</code>. The type makes the two mutually exclusive.</td>
</tr>
<tr>
<td><code>waitForCompletion</code></td>
<td>If <code>true</code>, a tick that arrives while the previous run is still going is skipped entirely.</td>
</tr>
<tr>
<td><code>disabled</code></td>
<td>If <code>true</code>, the job is registered but never started. Defaults to <code>false</code>.</td>
</tr>
<tr>
<td><code>threshold</code></td>
<td>How late, in milliseconds, a tick may fire before it is skipped instead of run. Defaults to 250.</td>
</tr>
<tr>
<td><code>initialDelay</code></td>
<td>Milliseconds to wait after bootstrap before the job starts. Later runs follow the schedule.</td>
</tr>
<tr>
<td><code>unrefTimeout</code></td>
<td>Lets the process exit even though the job's timer is pending.</td>
</tr>
</tbody>
</table>
<p>Without a <code>timeZone</code> or <code>utcOffset</code>, cron 4.4.0 uses the system time zone (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/src/time.ts">time.ts</a>, <a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>). For how that interacts with daylight saving, see <a href="https://cronradar.com/blog/cron-timezone">cron time zones</a>.</p>
<h3>CronExpression Mixes Five- and Six-Field Strings</h3>
<p>The enum is a set of string constants, and they do not share one format (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/enums/cron-expression.enum.ts">cron-expression.enum.ts</a>):</p>
<table>
<thead>
<tr>
<th>Constant</th>
<th>Value</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>EVERY_30_SECONDS</code></td>
<td><code>*/30 * * * * *</code></td>
<td>6</td>
</tr>
<tr>
<td><code>EVERY_MINUTE</code></td>
<td><code>*/1 * * * *</code></td>
<td>5</td>
</tr>
<tr>
<td><code>EVERY_5_MINUTES</code></td>
<td><code>0 */5 * * * *</code></td>
<td>6</td>
</tr>
<tr>
<td><code>EVERY_HOUR</code></td>
<td><code>0 0-23/1 * * *</code></td>
<td>5</td>
</tr>
<tr>
<td><code>EVERY_DAY_AT_2AM</code></td>
<td><code>0 02 * * *</code></td>
<td>5</td>
</tr>
<tr>
<td><code>EVERY_DAY_AT_MIDNIGHT</code></td>
<td><code>0 0 * * *</code></td>
<td>5</td>
</tr>
<tr>
<td><code>MONDAY_TO_FRIDAY_AT_1AM</code></td>
<td><code>0 0 01 * * 1-5</code></td>
<td>6</td>
</tr>
</tbody>
</table>
<p>Both forms run correctly in Nest. The difference matters when you copy the schedule into another tool that expects five fields, such as a monitor.</p>
<h2>What Happens When a Handler Throws</h2>
<p>The explorer wraps every declarative <code>@Cron()</code>, <code>@Interval()</code>, and <code>@Timeout()</code> method before it is scheduled (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.explorer.ts">schedule.explorer.ts</a>):</p>
<pre><code class="language-typescript">private wrapFunctionInTryCatchBlocks(methodRef: Function, instance: object) {
  return async (...args: unknown[]) => {
    try {
      await methodRef.call(instance, ...args);
    } catch (error) {
      this.logger.error(error);
    }
  };
}
</code></pre>
<p>Because the method is awaited, both a synchronous throw and a rejected promise land in the <code>catch</code>. The error is logged through Nest's <code>Logger</code> and goes nowhere else: the process keeps running, the job stays scheduled, and the next tick runs as usual. Nothing outside the log records that the run failed.</p>
<p>Jobs added with <code>SchedulerRegistry.addCronJob()</code> also get a Nest try/catch, but it wraps the <code>CronJob</code>'s <code>fireOnTick</code>, not your callback. In cron 4.4.0, <code>fireOnTick</code> catches callback errors first and sends them to the job's <code>errorHandler</code> if one is set, or to <code>console.error('[Cron] error in callback', error)</code> otherwise, so they do not reach Nest's <code>Logger</code> (<a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.registry.ts">scheduler.registry.ts</a>, <a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/src/job.ts">job.ts</a>).</p>
<h3>Two Ways a Tick Is Skipped Without an Error</h3>
<p>Both happen inside cron 4.4.0 (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/src/job.ts">job.ts</a>):</p>
<ul>
<li><strong><code>waitForCompletion: true</code></strong> returns early from a tick while the previous run is in progress. Without it, an async handler's promise is not awaited, so a slow run and the next tick overlap. The flag only knows about runs in its own process. For overlap in general, see <a href="https://cronradar.com/blog/prevent-overlapping-cron-jobs">prevent overlapping cron jobs</a>.</li>
<li><strong>A late tick past <code>threshold</code></strong> is dropped. When the timer fires more than 250 ms late (the option's doc comment in <a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/cron.decorator.ts">cron.decorator.ts</a> blames slow or busy hardware), cron logs a warning ending in "Skipping execution as it exceeds threshold (250ms)." and does not run the job. Within the threshold, it logs a warning and runs it immediately.</li>
</ul>
<h2>SchedulerRegistry</h2>
<p>Inject <code>SchedulerRegistry</code> to control named jobs at runtime (<a href="https://docs.nestjs.com/application/task-scheduling">docs</a>, <a href="https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.registry.ts">scheduler.registry.ts</a>):</p>
<pre><code class="language-typescript">import { Injectable } from '@nestjs/common'
import { SchedulerRegistry } from '@nestjs/schedule'
import { CronJob } from 'cron'

@Injectable()
export class JobsAdmin {
  constructor(private readonly schedulerRegistry: SchedulerRegistry) {}

  pauseReport() {
    const job = this.schedulerRegistry.getCronJob('nightly-report')
    job.stop()
    return job.lastDate() // Date of the last run, or null
  }

  addCleanup() {
    const job = new CronJob('0 3 * * *', () => {
      // errors here go to cron's console.error, not Nest's Logger
    })
    this.schedulerRegistry.addCronJob('cleanup', job)
    job.start()
  }
}
</code></pre>
<p><code>getCronJobs()</code> returns a <code>Map</code> of name to <code>CronJob</code>. <code>nextDate()</code> returns a Luxon <code>DateTime</code> and throws if a job has no future run. Intervals and timeouts have matching <code>getInterval</code>, <code>addInterval</code>, <code>deleteInterval</code>, <code>getTimeout</code>, <code>addTimeout</code>, and <code>deleteTimeout</code> methods.</p>
<h2>Every Replica Runs Every @Cron</h2>
<p>The scheduler is an in-process timer, so there is no shared state between instances. The NestJS docs state it directly: "The scheduler runs every job in every process of your application." With three instances behind a load balancer, a <code>@Cron('0 2 * * *')</code> job runs three times a night (<a href="https://docs.nestjs.com/application/task-scheduling">task scheduling docs</a>). Each Kubernetes replica or cluster worker is its own process, so each one fires the job. <a href="https://cronradar.com/blog/distributed-cron">Distributed cron</a> covers the general problem.</p>
<h3>Running a Job on One Instance</h3>
<p>The documented answer is <a href="https://docs.nestjs.com/reliability/locks"><code>@nestjs/locks</code></a>, at 0.0.5 on <a href="https://www.npmjs.com/package/@nestjs/locks/v/0.0.5">npm</a> (checked October 30, 2026). Add <code>@OnOneInstance()</code> next to the <code>@Cron()</code>:</p>
<pre><code class="language-typescript">import { Injectable } from '@nestjs/common'
import { OnOneInstance } from '@nestjs/locks'
import { Cron } from '@nestjs/schedule'

@Injectable()
export class InvoicesJob {
  @Cron('0 2 * * *')
  @OnOneInstance({ key: 'invoices:nightly-export' })
  async exportInvoices() {
    // ...
  }
}
</code></pre>
<p>What the <a href="https://docs.nestjs.com/reliability/locks">Distributed locks</a> page documents:</p>
<ul>
<li>The instances share a lock store, such as your PostgreSQL database or Redis. The page shows how to register <code>LocksModule</code> and the store.</li>
<li>The first instance to fire the job takes a lease and keeps renewing it. On the other instances, the call resolves without running the method.</li>
<li>When the owner dies, its lease expires after <code>ttl</code> (default <code>'30s'</code>), and the next instance to tick takes the job over. Ticks during that window are skipped, and "Missed ticks aren't caught up".</li>
<li><code>@WithoutOverlapping()</code> skips a tick while a previous run is still going on any instance.</li>
</ul>
<p>The same page explains why the usual fixes fall short. A single <code>RUN_JOBS=true</code> instance "works until a rolling deploy has two of them for a minute", and <code>waitForCompletion: true</code> "stops a job from overlapping itself, on one instance". Turning jobs off with <code>forRoot({ cronJobs: false })</code> on all but one instance is the same flag approach. Either way, write the job so a second run does no harm, as in <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">idempotent scheduled jobs</a>.</p>
<h2>Reporting Each Run to CronRadar</h2>
<p>The scheduler logs errors and keeps going, and a skipped or missed tick logs at most a warning. An external check-in turns each run into a record, and a missing record into an alert.</p>
<p>The CronRadar Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Report over HTTP instead. HTTP check-ins do not discover jobs automatically, so each <code>@Cron()</code> method needs its own monitor key and its schedule.</p>
<p>This helper uses the built-in <code>fetch</code> with a five-second timeout and Basic authentication, with the API key as the username and an empty password. Monitoring errors are logged and never change the job's result. The job's own error is rethrown, so Nest's wrapper still logs it:</p>
<pre><code class="language-typescript">import { randomUUID } from 'node:crypto'

const CRONRADAR_PING_URL = 'https://cron.life/api/v1/ping'

async function report(
  monitorKey: string,
  event: 'start' | 'complete' | 'fail',
  params: Record&#x3C;string, string>,
): Promise&#x3C;void> {
  const apiKey = process.env.CRONRADAR_API_KEY
  if (!apiKey) return

  const query = new URLSearchParams(params).toString()
  const url = `${CRONRADAR_PING_URL}/${encodeURIComponent(monitorKey)}/${event}?${query}`

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` },
      signal: AbortSignal.timeout(5000),
    })
    if (!response.ok) console.error(`CronRadar returned ${response.status} for ${monitorKey}`)
  } catch (error) {
    console.error(`CronRadar ${event} report failed for ${monitorKey}: ${(error as Error).message}`)
  }
}

export async function monitored(
  monitorKey: string,
  schedule: string,
  task: () => Promise&#x3C;void>,
): Promise&#x3C;void> {
  const runId = randomUUID()
  await report(monitorKey, 'start', { schedule, run_id: runId })
  try {
    await task()
  } catch (error) {
    // Keep the message generic: error details can contain data that must not leave the app.
    await report(monitorKey, 'fail', {
      run_id: runId,
      message: `${monitorKey} threw ${(error as Error).name}`,
    })
    throw error
  }
  await report(monitorKey, 'complete', { run_id: runId })
}
</code></pre>
<p>Call it inside the method body, after the decorators:</p>
<pre><code class="language-typescript">import { Injectable } from '@nestjs/common'
import { OnOneInstance } from '@nestjs/locks'
import { Cron } from '@nestjs/schedule'
import { monitored } from './cronradar'

@Injectable()
export class InvoicesJob {
  @Cron('0 2 * * *', { name: 'invoice-export', timeZone: 'UTC' })
  @OnOneInstance({ key: 'invoices:nightly-export' })
  async exportInvoices() {
    await monitored('invoice-export', '0 2 * * *', () => this.runExport())
  }

  private async runExport() {
    // ...
  }
}
</code></pre>
<p>Because <code>@OnOneInstance()</code> skips the method on instances that do not hold the lease, only the instance that runs the job reports it.</p>
<p>CronRadar reads schedules as standard five-field cron and evaluates them in UTC (<a href="https://docs.cronradar.com/schedules">schedules</a>). Run the job in UTC and send the same expression. For a six-field <code>CronExpression</code> constant, drop the seconds field: <code>0 */5 * * * *</code> becomes <code>*/5 * * * *</code>. A job that runs more than once a minute, such as <code>EVERY_30_SECONDS</code>, has no five-field form.</p>
<p>The start report carries the schedule, so CronRadar creates the monitor the first time it sees the key. A fail report opens an incident immediately. A start without a completion, and a run that never arrives, are caught against the schedule, which covers the thrown error Nest only logs, the skipped tick, and the instance that stopped. Alerts go to email, Slack, webhook, Discord, or PagerDuty. Keep <code>CRONRADAR_API_KEY</code> in your deployment's secret store or an environment file that is not committed, and use a separate CronRadar application for each environment. Set the <a href="https://cronradar.com/blog/cron-grace-period">grace period</a> to cover the job's usual duration.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why does my NestJS cron job run twice?</h3>
<p>Either more than one process is running the app, or <code>ScheduleModule.forRoot()</code> is imported in more than one module. The scheduler runs every job in every process, and each extra <code>forRoot()</code> import registers every handler again. <a href="https://cronradar.com/blog/cron-job-running-twice">Cron job running twice</a> covers the other causes.</p>
<h3>Does an exception in a @Cron method crash the app?</h3>
<p>No. Declarative <code>@Cron()</code>, <code>@Interval()</code>, and <code>@Timeout()</code> methods are wrapped in a try/catch that logs the error through Nest's <code>Logger</code>. The job stays scheduled, and nothing outside the log records the failure.</p>
<h3>Does waitForCompletion work across instances?</h3>
<p>No. It skips a tick only while the same <code>CronJob</code>, in the same process, is still running its previous callback. Across instances, use <code>@WithoutOverlapping()</code> from <code>@nestjs/locks</code>.</p>
<h3>What time zone does @Cron use by default?</h3>
<p>The system time zone of the process. Pass <code>timeZone</code> (an IANA name such as <code>'Europe/Paris'</code>) or <code>utcOffset</code>, but not both.</p>
<h3>Can CronRadar discover NestJS jobs automatically?</h3>
<p>No. The Node.js SDK is temporarily unavailable, and HTTP check-ins do not discover jobs. Report each job with its own monitor key and five-field UTC schedule.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>nestjs cron</category>
      <category>nestjs schedule module</category>
      <category>nestjs cron multiple instances</category>
      <category>nestjs SchedulerRegistry</category>
      <category>nestjs cron error handling</category>
    </item>
    <item>
      <title><![CDATA[Node.js Schedulers Compared: cron, node-cron, node-schedule, croner, Agenda, BullMQ]]></title>
      <link>https://cronradar.com/blog/node-js-schedulers-compared</link>
      <guid>https://cronradar.com/blog/node-js-schedulers-compared</guid>
      <description><![CDATA[cron, node-cron, node-schedule, croner, Agenda, and BullMQ compared on cron syntax, time zones, persistence, overlap, multiple instances, and releases.]]></description>
      <content:encoded><![CDATA[<p><strong>cron, node-cron, node-schedule, and croner keep schedules in your process's memory: a restart loses them, and each instance of the app runs its own copy unless you add coordination. Agenda and BullMQ store schedules in a database or Redis, so they survive restarts and many workers can share one schedule.</strong> Pick an in-process library for single-instance apps where a missed run is acceptable, and a persistent one when runs must survive deploys or the app runs on several instances.</p>
<p>This guide covers cron 4.4.0, node-cron 4.6.0, node-schedule 2.1.2, croner 11.0.0, Agenda 6.2.6, and BullMQ 6.5.2, each the current release on the <a href="https://registry.npmjs.org/">npm registry</a> (checked October 30, 2026). For error handling, restarts, and overlaps in node-cron and node-schedule, see <a href="https://cronradar.com/blog/node-js-cron-monitoring">Node.js cron job monitoring</a>; for BullMQ Job Schedulers in depth, see <a href="https://cronradar.com/blog/bullmq-repeatable-jobs">BullMQ repeatable jobs and Job Schedulers</a>. The .NET equivalent of this comparison is <a href="https://cronradar.com/blog/hangfire-vs-quartz-vs-backgroundservice">Hangfire vs Quartz.NET vs BackgroundService</a>.</p>
<h2>Two Models: Timers in the Process, or Jobs in a Store</h2>
<p>The first four libraries are timers. You call a function with a cron pattern, the library sets a timer for the next match, and the job is a JavaScript object. node-schedule's README says it plainly: jobs "will only fire as long as your script is running, and the schedule will disappear when execution completes" (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README at v2.1.2</a>). croner "operates in-memory, with no need for a database" (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README at 11.0.0</a>), and node-cron "coordinates but does not persist state to a database" (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README at v4.6.0</a>).</p>
<p>Agenda and BullMQ are job systems. The schedule is a record in a store, workers poll or subscribe to it, and the process that runs a job need not be the one that created the schedule. Agenda 6 has pluggable backends for MongoDB, PostgreSQL, and Redis, each a separate package (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/README.md">README at agenda@6.2.6</a>). A BullMQ Job Scheduler lives in Redis, the default backend, or in PostgreSQL with the optional PostgreSQL backend (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/docs/gitbook/guide/postgresql.md">PostgreSQL backend guide at v6.5.2</a>), and produces each run as a job that waits in the delayed state until it is due (<a href="https://docs.bullmq.io/guide/job-schedulers/">Job Schedulers docs</a>).</p>
<h2>Node.js Schedulers at a Glance</h2>
<table>
<thead>
<tr>
<th></th>
<th>cron (kelektiv)</th>
<th>node-cron</th>
<th>node-schedule</th>
<th>croner</th>
<th>Agenda</th>
<th>BullMQ</th>
</tr>
</thead>
<tbody>
<tr>
<td>Latest release</td>
<td>4.4.0, December 9, 2025 (<a href="https://registry.npmjs.org/cron">npm</a>)</td>
<td>4.6.0, July 5, 2026 (<a href="https://registry.npmjs.org/node-cron">npm</a>)</td>
<td>2.1.2, October 24, 2026 (<a href="https://registry.npmjs.org/node-schedule">npm</a>)</td>
<td>11.0.0, October 26, 2026 (<a href="https://registry.npmjs.org/croner">npm</a>)</td>
<td>6.2.6, July 21, 2026 (<a href="https://registry.npmjs.org/agenda">npm</a>)</td>
<td>6.5.2, October 30, 2026 (<a href="https://registry.npmjs.org/bullmq">npm</a>)</td>
</tr>
<tr>
<td>Weekly downloads, Oct 23-29, 2026</td>
<td>10,026,652 (<a href="https://api.npmjs.org/downloads/point/last-week/cron">api</a>)</td>
<td>6,769,583 (<a href="https://api.npmjs.org/downloads/point/last-week/node-cron">api</a>)</td>
<td>4,753,915 (<a href="https://api.npmjs.org/downloads/point/last-week/node-schedule">api</a>)</td>
<td>14,035,929 (<a href="https://api.npmjs.org/downloads/point/last-week/croner">api</a>)</td>
<td>228,689 (<a href="https://api.npmjs.org/downloads/point/last-week/agenda">api</a>)</td>
<td>13,183,574 (<a href="https://api.npmjs.org/downloads/point/last-week/bullmq">api</a>)</td>
</tr>
<tr>
<td>Seconds field</td>
<td>Optional sixth field at the front (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td>Optional sixth field at the front (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td>Optional sixth field at the front (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td>Optional second, plus optional seventh field for year (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>Patterns parsed by cron-parser (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/utils/nextRunAt.ts">nextRunAt.ts</a>)</td>
<td>Optional sixth field at the front (<a href="https://docs.bullmq.io/guide/job-schedulers/repeat-strategies">repeat strategies</a>)</td>
</tr>
<tr>
<td>Extensions</td>
<td>Names for months and weekdays; no <code>L</code>, <code>W</code>, or <code>#</code> listed (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td><code>L</code>, <code>L-n</code>, <code>W</code>, <code>LW</code>, <code>#</code>, <code>5L</code>, <code>?</code> (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td><code>#</code>; no <code>L</code> or <code>W</code> (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td><code>L</code>, <code>W</code>, <code>#</code>, <code>?</code>, <code>+</code> for AND (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>Also human intervals such as <code>'1 hour'</code> (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/README.md">README</a>)</td>
<td>Also <code>every</code> in milliseconds (<a href="https://docs.bullmq.io/guide/job-schedulers/repeat-strategies">repeat strategies</a>)</td>
</tr>
<tr>
<td>Time zone</td>
<td><code>timeZone</code> or <code>utcOffset</code>; default local (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td><code>timezone</code> option (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td><code>tz</code> on a <code>RecurrenceRule</code> (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td><code>timezone</code> or <code>utcOffset</code> (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td><code>timezone</code> option on <code>every()</code> (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/index.ts">index.ts</a>)</td>
<td><code>tz</code> repeat option (<a href="https://docs.bullmq.io/guide/job-schedulers/repeat-options">repeat options</a>)</td>
</tr>
<tr>
<td>Persistence</td>
<td>None documented; jobs are <code>CronJob</code> objects (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td>None (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td>None (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td>None, in memory (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>MongoDB, PostgreSQL, or Redis backend (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/README.md">README</a>)</td>
<td>Redis by default, or PostgreSQL (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/docs/gitbook/guide/postgresql.md">PostgreSQL backend</a>)</td>
</tr>
<tr>
<td>Late or missed runs</td>
<td><code>threshold</code>: a late tick within it runs, beyond it is skipped (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td><code>execution:missed</code> for each slot the timer woke too late to run (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/src/scheduler/plan-beat.ts#L52-L64">plan-beat.ts</a>)</td>
<td>Schedule is gone when the script stops (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td>Not documented (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>One stored job per <code>every()</code> name; next run computed from <code>lastRunAt</code> (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/index.ts">index.ts</a>, <a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/utils/nextRunAt.ts">nextRunAt.ts</a>)</td>
<td>One delayed job per scheduler, so an overdue run happens once (<a href="https://docs.bullmq.io/guide/job-schedulers/">Job Schedulers</a>); the next run is computed from now (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/src/classes/job-scheduler.ts#L180-L201">job-scheduler.ts</a>)</td>
</tr>
<tr>
<td>Overlap protection</td>
<td><code>waitForCompletion</code> skips ticks while one runs (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td><code>noOverlap</code> (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td>None documented (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td><code>protect</code> (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>Job locks with <code>lockLifetime</code>, per-job <code>concurrency</code> (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/README.md">README</a>)</td>
<td>None built in for a scheduler: the next run is created when the current one starts, so runs can overlap with several workers or <code>concurrency</code> above 1 (<a href="https://docs.bullmq.io/guide/job-schedulers/">Job Schedulers</a>); use a dedicated queue with <code>queue.setGlobalConcurrency(1)</code> (<a href="https://docs.bullmq.io/guide/queues/global-concurrency">global concurrency</a>)</td>
</tr>
<tr>
<td>Multiple instances</td>
<td>No coordination documented (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README</a>)</td>
<td><code>distributed: true</code>, env-var flag or Redis coordinator (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README</a>)</td>
<td>README points to Agenda or Bree for multi-node (<a href="https://github.com/node-schedule/node-schedule/blob/v2.1.2/README.md">README</a>)</td>
<td>No coordination documented (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README</a>)</td>
<td>Shared store with locking (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/README.md">README</a>)</td>
<td>Shared Redis or PostgreSQL; schedules live in the store, not in each process (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/docs/gitbook/guide/postgresql.md">PostgreSQL backend</a>)</td>
</tr>
</tbody>
</table>
<h2>Cron Dialects Differ More Than They Look</h2>
<p>All six accept a five-field crontab pattern, but the same string does not always mean the same thing.</p>
<p><strong>Day-of-month and day-of-week.</strong> In standard cron, <code>0 9 1 * 1</code> runs on the 1st of the month and on every Monday: when both day fields are restricted, a match on either one is enough (<a href="https://man7.org/linux/man-pages/man5/crontab.5.html">crontab(5)</a>). croner keeps that OR logic by default and offers <code>+MON</code> or <code>{ domAndDow: true }</code> for AND (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README at 11.0.0</a>). node-cron 4.6.0 combines the two fields with AND: both must match (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README at v4.6.0</a>). A pattern moved from crontab to node-cron can run on far fewer days. Avoid restricting both fields when a schedule has to be portable.</p>
<p><strong>Seconds.</strong> A six-field pattern puts seconds first in cron, node-cron, node-schedule, croner, and BullMQ. kelektiv's cron defaults a missing seconds field to 0 (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README at v4.4.0</a>), and croner's <code>mode</code> option can force 5-, 6-, or 7-part parsing instead of detecting it (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README at 11.0.0</a>).</p>
<p><strong>Weekday numbers.</strong> node-cron borrows <code>L</code>, <code>W</code>, <code>#</code>, and <code>?</code> from Quartz but keeps standard weekday numbering (<code>1</code> is Monday), so Quartz expressions do not carry over unchanged (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README at v4.6.0</a>). croner switches to Quartz numbering only with <code>alternativeWeekdays: true</code> (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README at 11.0.0</a>). In kelektiv's cron 4.x, months are numbered 1-12; the README's migration section notes they were 0-11 before (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README at v4.4.0</a>).</p>
<p>For the fields themselves, see <a href="https://cronradar.com/blog/cron-expression">cron expression syntax</a>.</p>
<h2>Time Zones and Daylight Saving Time</h2>
<p>kelektiv's cron runs in local time unless you pass <code>timeZone</code> (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README at v4.4.0</a>). BullMQ passes its repeat options, including <code>tz</code>, to cron-parser (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/src/classes/job-scheduler.ts#L517-L540">job-scheduler.ts</a>), and cron-parser 5.10.2, the version <a href="https://registry.npmjs.org/bullmq/6.5.2">bullmq 6.5.2 depends on</a>, uses the process's local zone when no zone is given (<a href="https://github.com/harrisiirak/cron-parser/blob/v5.10.2/src/CronDate.ts#L34-L48">CronDate.ts</a>). Set <code>tz</code> as a <a href="https://docs.bullmq.io/guide/job-schedulers/repeat-options">repeat option</a>; the <a href="https://cronradar.com/blog/bullmq-repeatable-jobs">BullMQ guide</a> has more. Set the zone explicitly in every library.</p>
<p>The documented daylight saving behavior differs:</p>
<ul>
<li><strong>node-cron:</strong> in a fall-back the repeated hour runs once, so a <code>*/15</code> schedule can pause for up to the length of the shift. The README recommends <code>timezone: 'UTC'</code> for an uninterrupted interval (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README at v4.6.0</a>).</li>
<li><strong>croner:</strong> fixed-time jobs such as <code>0 30 2 * * *</code> run once at the first occurrence of a repeated time and, for a time that doesn't exist, at the first valid time after the gap. Interval jobs run on both occurrences of a repeated time and skip times that don't exist (<a href="https://github.com/Hexagon/croner/blob/11.0.0/README.md">README at 11.0.0</a>).</li>
</ul>
<p>The general problem is covered in <a href="https://cronradar.com/blog/cron-daylight-saving-time">cron and daylight saving time</a>. Running in UTC avoids it.</p>
<h2>What Happens After a Restart or Downtime</h2>
<p>For the four in-process libraries, a run that falls due while the process is down never happens, and nothing records that it didn't. The late-run features cover a running process that woke up late: kelektiv's <code>threshold</code> decides whether a late tick still runs (<a href="https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md">README at v4.4.0</a>), and node-cron emits <code>execution:missed</code> for each slot its timer woke more than <code>missedExecutionTolerance</code> (1000 ms by default) after (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/src/tasks/scheduled-task.ts#L88-L95">scheduled-task.ts</a>, <a href="https://github.com/node-cron/node-cron/blob/v4.6.0/src/scheduler/plan-beat.ts#L52-L64">plan-beat.ts</a> at v4.6.0).</p>
<p>Agenda's <code>every()</code> saves a single job record for the name (<code>type = 'single'</code> in <a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/index.ts">index.ts</a>) and computes the next run from <code>lastRunAt</code> with cron-parser (<a href="https://github.com/agenda/agenda/blob/agenda%406.2.6/packages/agenda/src/utils/nextRunAt.ts">nextRunAt.ts</a>). Because there is one record with one next run time, missed runs cannot pile up as separate jobs. BullMQ behaves similarly: the overdue job runs once, late, and the next run is calculated from the current time, for a pattern the next occurrence after now (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/src/classes/job-scheduler.ts#L180-L201">job-scheduler.ts at v6.5.2</a>) and for <code>every</code> on Redis the next slot after now (<a href="https://github.com/taskforcesh/bullmq/blob/v6.5.2/src/commands/includes/getJobSchedulerEveryNextMillis.lua#L22-L38">getJobSchedulerEveryNextMillis.lua</a>). The <a href="https://cronradar.com/blog/bullmq-repeatable-jobs">BullMQ guide</a> covers this in depth.</p>
<p>Neither replays every missed slot. If each slot matters, for example a report per day, make the job find and process the gap itself; see <a href="https://cronradar.com/blog/missed-runs-after-downtime">missed runs after downtime</a>.</p>
<h2>Overlap and Multiple Instances</h2>
<p>Three in-process libraries can skip a run while the previous one is still going: <code>waitForCompletion</code> in kelektiv's cron, <code>noOverlap</code> in node-cron, and <code>protect</code> in croner. node-schedule has none, so track a running flag yourself (<a href="https://cronradar.com/blog/node-js-cron-monitoring">example</a>). These options work within one process only. Two instances of the app are two schedulers, and each runs every job; see <a href="https://cronradar.com/blog/prevent-overlapping-cron-jobs">preventing overlapping cron jobs</a> for locks that span processes.</p>
<p>node-cron 4.6.0 is the only in-process library here with built-in coordination: <code>distributed: true</code> makes one instance run each firing, using an environment-variable flag by default or a Redis coordinator for high availability (<a href="https://github.com/node-cron/node-cron/blob/v4.6.0/README.md">README at v4.6.0</a>). Agenda and BullMQ coordinate through their store by design. For the patterns behind all of this, see <a href="https://cronradar.com/blog/distributed-cron">distributed cron</a>.</p>
<h2>How to Choose</h2>
<ul>
<li><strong>One instance, missed runs tolerable, standard crontab patterns:</strong> kelektiv's <code>cron</code> or croner. croner adds <code>L</code>, <code>W</code>, <code>#</code>, a year field, and documented DST rules, and keeps standard OR semantics for day fields.</li>
<li><strong>One instance now, several later, without a database:</strong> node-cron 4, for <code>noOverlap</code>, <code>distributed</code>, and lifecycle events. Remember its AND rule for day-of-month plus day-of-week.</li>
<li><strong>Calendar-style rules more than cron strings:</strong> node-schedule's <code>RecurrenceRule</code>, with an <code>error</code> listener on every job (<a href="https://cronradar.com/blog/node-js-cron-monitoring">why</a>).</li>
<li><strong>Schedules must survive deploys and run on one of many workers:</strong> Agenda on MongoDB, PostgreSQL, or Redis, or BullMQ on Redis or PostgreSQL. BullMQ if you need a work queue too.</li>
</ul>
<h2>Monitoring Whichever You Pick</h2>
<p>None of these libraries can tell you about a run that never started: a stopped process, a worker that never came up, or a schedule removed from the store all fail without an error. An outside monitor that knows the schedule catches those.</p>
<p>CronRadar's <code>cronradar</code> npm package is unavailable, so Node.js jobs report over HTTP check-ins. Check-ins do not discover jobs automatically: each job reports with its own monitor key and its schedule. The <a href="https://cronradar.com/blog/node-js-cron-monitoring">Node.js monitoring guide</a> has a <code>monitored(monitorKey, schedule, task)</code> wrapper that sends start, complete, and fail check-ins with HTTP Basic authentication (the API key as the username, empty password), a five-second timeout, and the key read from <code>CRONRADAR_API_KEY</code> in the environment. It wraps any job function, so it works the same way with each library:</p>
<pre><code class="language-javascript">const { Cron } = require('croner')

// monitored() is the wrapper from the Node.js monitoring guide
new Cron(
  '0 3 * * *',
  { timezone: 'UTC', protect: true },
  monitored('nightly-backup', '0 3 * * *', performBackup),
)
</code></pre>
<pre><code class="language-javascript">const { CronJob } = require('cron')

CronJob.from({
  cronTime: '0 3 * * *',
  onTick: monitored('nightly-backup', '0 3 * * *', performBackup),
  timeZone: 'UTC',
  waitForCompletion: true,
  start: true,
})
</code></pre>
<p>For BullMQ, check in from the worker's processor, as shown in the <a href="https://cronradar.com/blog/bullmq-repeatable-jobs">BullMQ guide</a>.</p>
<p>CronRadar reads schedules as standard five-field cron evaluated in UTC (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>). Run the job in UTC and send a five-field pattern: drop a leading seconds field (<code>0 0 2 * * *</code> becomes <code>0 2 * * *</code>). Five fields cannot express <code>L</code> or <code>W</code>, so schedule such a job on every candidate date and let it decide whether to do the work.</p>
<p>A monitor is <code>pending</code> until its first check-in and <code>healthy</code> while runs arrive. A late run is <code>warning</code> inside its grace period and <code>critical</code> once grace expires; a failure check-in makes it <code>critical</code> straight away. Alerts go to email, Slack, webhook, Discord, or PagerDuty.</p>
<h2>Frequently Asked Questions</h2>
<h3>What is the difference between cron and node-cron on npm?</h3>
<p>They are separate projects. <code>cron</code> is maintained in the kelektiv/node-cron repository and exports <code>CronJob</code>; <code>node-cron</code> is maintained in the node-cron/node-cron repository and exports <code>schedule()</code>. Their dialects and options differ: for example, node-cron combines day-of-month and day-of-week with AND.</p>
<h3>Which Node.js scheduler supports seconds?</h3>
<p>cron, node-cron, node-schedule, croner, and BullMQ document an optional seconds field at the front of a six-field pattern, and croner also accepts a seventh field for the year. Agenda passes cron patterns to cron-parser.</p>
<h3>Which Node.js schedulers keep jobs across restarts?</h3>
<p>Agenda, which stores jobs in MongoDB, PostgreSQL, or Redis, and BullMQ, which stores Job Schedulers in Redis or PostgreSQL. cron, node-cron, node-schedule, and croner keep schedules in memory and lose them when the process stops.</p>
<h3>Do Agenda and BullMQ run every missed job after downtime?</h3>
<p>No. Each keeps one pending run per schedule, so after downtime the overdue run happens once and the next is calculated from there. Make the job process the missed period itself if every slot matters.</p>
<h3>Does CronRadar integrate with node-cron, croner, Agenda, or BullMQ?</h3>
<p>No. The Node.js SDK is unavailable, and CronRadar does not discover Node.js jobs. Each job reports through HTTP check-ins with its own monitor key and schedule.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>node.js scheduler comparison</category>
      <category>node-cron vs node-schedule</category>
      <category>croner vs node-cron</category>
      <category>agenda vs bullmq</category>
      <category>best node.js cron library</category>
    </item>
    <item>
      <title><![CDATA[Opsgenie Heartbeat Migration: Intervals, JSM Heartbeats, and Cron Schedules]]></title>
      <link>https://cronradar.com/blog/opsgenie-heartbeat-migration</link>
      <guid>https://cronradar.com/blog/opsgenie-heartbeat-migration</guid>
      <description><![CDATA[Opsgenie shuts off April 5, 2027. How its heartbeats work, the Jira Service Management equivalent, turning an interval into a cron schedule, and a checklist.]]></description>
      <content:encoded><![CDATA[<p><strong>Atlassian stopped selling Opsgenie on June 4, 2025, and on April 5, 2027 it shuts off access and deletes any data that hasn't been migrated (<a href="https://www.atlassian.com/blog/company-news/evolution-of-it-operations">announcement</a>).</strong> Every Opsgenie heartbeat needs a new home before then: Jira Service Management, where migrated heartbeats keep the same interval model, or a cron monitor that expects each run at its scheduled time plus a grace period. The conversion is mostly arithmetic, and it shows which skipped runs your current interval never alerted on.</p>
<p>This guide covers heartbeat checks only, not alert routing, on-call schedules, or other Opsgenie features. It covers Opsgenie's Heartbeat API v2 and the heartbeat endpoints of the Jira Service Management Cloud operations REST API, whose URLs use v1, the <a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/intro/">only version available</a>, as documented in the <a href="https://docs.opsgenie.com/docs/heartbeat-api">Opsgenie API docs</a> and on <a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-heartbeats/">Atlassian's developer site</a> (checked October 30, 2026). <a href="https://cronradar.com/blog/cron-dead-mans-switch">The dead man's switch</a> explains the heartbeat pattern itself.</p>
<h2>Atlassian's Opsgenie Dates</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>What happens</th>
</tr>
</thead>
<tbody>
<tr>
<td>June 4, 2025: end of sale</td>
<td>No new purchases or sign-ups, whether from Atlassian, partners, or resellers, and no edition upgrades or downgrades. Renewals are allowed only if the subscription term ends before end of support.</td>
</tr>
<tr>
<td>April 5, 2027: end of support</td>
<td>Access to Opsgenie is shut off, and any data still in Opsgenie that hasn't been migrated is deleted.</td>
</tr>
</tbody>
</table>
<p>Both dates come from Atlassian's <a href="https://www.atlassian.com/blog/company-news/evolution-of-it-operations">announcement</a> of March 3, 2025, which points customers who want to keep their alert and on-call workflows to Jira Service Management. Atlassian's <a href="https://support.atlassian.com/opsgenie/docs/how-opsgenie-integrations-move-to-jira-service-management/">integration changes</a> page names Compass as the other destination. This guide covers the Jira Service Management side.</p>
<h2>How Opsgenie Heartbeats Work</h2>
<h3>A Named Interval, Reset by Every Ping</h3>
<p>A heartbeat is a unique, case-sensitive name and an interval. The <a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a> takes <code>interval</code> (minimum 1), <code>intervalUnit</code> (minutes, hours, or days), and <code>enabled</code>, and the <a href="https://support.atlassian.com/opsgenie/docs/add-heartbeats-to-monitor-external-systems/">heartbeat guide</a> says each heartbeat can be checked "up to once per minute." The interval runs from the last ping. With a 10-minute interval, "If a Heartbeat request is not received for more than 10 minutes," Opsgenie creates an alert. The API has no schedule or time-zone field: Opsgenie never knows when a run is due, only how long it has been since the last ping.</p>
<p>On expiry, Opsgenie creates an alert "soon after," using the heartbeat's <code>alertMessage</code>, <code>alertTags</code>, and <code>alertPriority</code>. Left unset, the priority is P3 and the message is the heartbeat's name followed by "is expired" (<a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a>), and every expiry alert carries the <code>Heartbeat</code> tag by default. The next ping makes the heartbeat active again, and "the expiry alert will be automatically closed soon after." Heartbeats are available on Opsgenie's Standard and Enterprise plans only (<a href="https://support.atlassian.com/opsgenie/docs/add-heartbeats-to-monitor-external-systems/">heartbeat guide</a>).</p>
<h3>Pinging a Heartbeat</h3>
<p>The API ping is a <code>GET</code>, <code>POST</code>, <code>PUT</code>, or <code>PATCH</code> to <code>https://api.opsgenie.com/v2/heartbeats/:heartbeatName/ping</code> with an <code>Authorization: GenieKey</code> header; EU accounts use <code>api.eu.opsgenie.com</code>. Opsgenie processes pings asynchronously and answers <code>PONG - Heartbeat received</code> without checking that the heartbeat exists, so a misspelled name gets the same answer as a real one (<a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a>).</p>
<p>The <a href="https://support.atlassian.com/opsgenie/docs/add-heartbeats-to-monitor-external-systems/">heartbeat guide</a> lists the other ways in: the Lamp command-line tool (<code>lamp heartbeat</code>), the Opsgenie Java API, the Script Monitoring Client, an email to the heartbeat's built-in address, and Auto Heartbeat Creation, an opt-in setting that, once enabled, creates and pings a heartbeat when a ping names one that doesn't exist, provided the API key belongs to a team.</p>
<h3>What a Heartbeat Can't Report</h3>
<p>The <a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a>'s operations are ping, add, get, list, update, delete, enable, and disable. A ping carries only the name: no start event, no failure, no exit status. A run that fails before it pings looks the same as one that never started, and so does a run that hangs; each is reported only when the interval runs out.</p>
<h2>Heartbeats in Jira Service Management</h2>
<h3>What the Migration Moves</h3>
<p>From Atlassian's <a href="https://support.atlassian.com/opsgenie/docs/advanced-alert-configurations-after-migration/">notes on advanced alert configurations after migration</a>:</p>
<ul>
<li>Heartbeats move automatically, but heartbeat monitoring "isn't available in Jira Service Management Free and Standard plans," only Premium and Enterprise (<a href="https://support.atlassian.com/opsgenie/docs/feature-changes-and-deprecations-in-jira-service-management/">feature changes</a>).</li>
<li>Heartbeats on the deprecated v1 version aren't moved. Recreate them by hand with Jira Service Management's API.</li>
<li>Heartbeats belong to teams, under <strong>Teams > Operations > Heartbeats</strong>. A site holds 2,000; a migrated total over the cap still moves, but you must remove the excess before adding more.</li>
<li>Email ping addresses move from opsgenie.net to atlassian.net. Update scripts that email the old address; <strong>Copy heartbeat name</strong> in the heartbeat's <strong>Actions</strong> menu copies the new one.</li>
</ul>
<p>Atlassian's <a href="https://support.atlassian.com/opsgenie/docs/feature-changes-and-deprecations-in-jira-service-management/">feature changes and deprecations</a> list adds two things. Heartbeats have moved to a team-based configuration, which you switch on by hand in Opsgenie under <strong>Settings > General > Heartbeat Configuration > Activate Heartbeat</strong>, before or after migration. And Opsgenie REST API endpoints have to be updated to Jira Service Management's, with Opsgenie's API key management replaced by Atlassian authentication.</p>
<h3>Same Model, New Endpoint and Credentials</h3>
<p>Jira Service Management describes the same check: heartbeats are expected at an interval set in minutes, hours, or days, and if one "is not received within a specified time frame," it generates an alert (<a href="https://support.atlassian.com/jira-service-management-cloud/docs/what-are-heartbeats/">What are heartbeats?</a>). A heartbeat is <code>Pending</code> before its first ping, <code>Responsive</code> while pings arrive, <code>Unresponsive</code> after a silence "far beyond the expected interval," and <code>Off</code> when turned off for maintenance. Neither that page nor the <a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-heartbeats/">Heartbeats API</a> says whether the interval counts from the last ping, as Opsgenie's guide does, so the alert times worked out below are Opsgenie's; check Jira Service Management's on a test heartbeat before relying on them.</p>
<p>The ping moves to a team-scoped URL. This is the documented request, adapted to read the credential from a file and the IDs from the environment, and to give up after five seconds:</p>
<pre><code class="language-bash"># JSM_AUTH holds "email:api_token" and is read from a file only the job's account can read.
# JSM_CLOUD_ID and JSM_TEAM_ID are the {cloudId} and {teamId} path parameters.
JSM_AUTH=$(cat /etc/jsm/heartbeat-auth)
curl --fail --silent --show-error --max-time 5 --output /dev/null \
  --request POST --user "$JSM_AUTH" \
  "https://api.atlassian.com/jsm/ops/api/$JSM_CLOUD_ID/v1/teams/$JSM_TEAM_ID/heartbeats/ping?name=nightly-backup"
</code></pre>
<p><code>GET</code>, <code>PUT</code>, and <code>PATCH</code> work too. The <a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-heartbeats/">Heartbeats API</a> lists the scopes <code>read:ops-config:jira-service-management</code> and <code>write:ops-config:jira-service-management</code>, answers <code>202</code> with <code>PONG - Heartbeat received</code>, and, like Opsgenie, doesn't check that the heartbeat exists.</p>
<p>The credential is the larger change. The reference authenticates with an Atlassian account's email and API token, not an integration's GenieKey, and Atlassian API tokens expire after 1 to 365 days, one year by default (<a href="https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/">API tokens</a>). Every host that pings needs the token, and a new one before it expires; when it lapses, pings stop resetting the heartbeats that depend on it. Atlassian's tracker has a suggestion to allow GenieKey pings, <a href="https://jira.atlassian.com/browse/JSDCLOUD-18241">JSDCLOUD-18241</a>, unresolved and in Gathering Interest status (checked October 30, 2026).</p>
<h2>Converting an Interval to a Cron Schedule Plus Grace</h2>
<h3>What Each Model Waits For</h3>
<p>An interval heartbeat alerts when the time since the last ping exceeds the interval. A CronRadar monitor goes <code>critical</code>, with last result <code>timeout</code>, an incident, and an alert, when the run due at a scheduled time hasn't checked in by that time plus a grace period; <a href="https://cronradar.com/blog/cron-grace-period">how CronRadar applies the grace period</a> has the timing rules.</p>
<p>The consequence for intervals: an interval has to be longer than the longest normal gap between pings, and a skipped run is caught only if the gap it leaves is longer than the interval. On a job with even gaps, that means an interval under two periods. On a job with uneven gaps, some skipped runs never alert. And because the interval counts from the previous ping, the alert time for a skipped run moves with how long the previous run took.</p>
<h3>Five Jobs, Both Ways</h3>
<p>Each job below sends its heartbeat when it finishes. Opsgenie creates its alert "soon after" expiry, so its times are the earliest possible. CronRadar times use a completion check-in and the grace shown.</p>
<table>
<thead>
<tr>
<th>Job (UTC)</th>
<th>Opsgenie interval</th>
<th>Opsgenie alert for a skipped run</th>
<th>CronRadar schedule</th>
<th align="right"><code>gracePeriod</code></th>
<th>CronRadar <code>critical</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Every 5 minutes, done about 30 s in</td>
<td>10 minutes</td>
<td>One skipped run: none, unless the next run finishes later than the last one did, since the gap is about 10 minutes. Two in a row (10:05, 10:10): after 10:10:30</td>
<td><code>*/5 * * * *</code></td>
<td align="right">120</td>
<td>After 10:07</td>
</tr>
<tr>
<td>Hourly at :20, done about 5 min in</td>
<td>90 minutes</td>
<td>10:20 skipped: after 10:55, 90 minutes after the 09:25 ping</td>
<td><code>20 * * * *</code></td>
<td align="right">600</td>
<td>After 10:30</td>
</tr>
<tr>
<td>Daily at 02:30, done 10 to 40 min in</td>
<td>25 hours</td>
<td>Tuesday skipped: between 03:40 and 04:10 Tuesday, depending on when Monday's run finished</td>
<td><code>30 2 * * *</code></td>
<td align="right">3,000</td>
<td>After 03:20</td>
</tr>
<tr>
<td>Weekdays at 09:00, done about 5 min in</td>
<td>73 hours, to clear Friday to Monday</td>
<td>Tuesday, Wednesday, or Thursday skipped: none, the gap is 48 hours. Monday: after 10:05 Monday. Friday: after 10:05 Sunday</td>
<td><code>0 9 * * 1-5</code></td>
<td align="right">600</td>
<td>After 09:10 the same day</td>
</tr>
<tr>
<td>The 1st at 04:00, done about 20 min in</td>
<td>32 days, to clear a 31-day month</td>
<td>March 1 skipped, after a 28-day February: after 04:20 on March 5</td>
<td><code>0 4 1 * *</code></td>
<td align="right">1,800</td>
<td>After 04:30 on March 1</td>
</tr>
</tbody>
</table>
<p>For a job with a fixed period and a steady run time, a grace of the interval minus the period plus the run time reproduces Opsgenie's alert time: 90 minus 60 plus 5, or 35 minutes, for the hourly job. A grace sized from the job is usually shorter, because a cron monitor knows when each run is due; <a href="https://cronradar.com/blog/cron-grace-period">choosing a grace period</a> works through the measurements, including how a <code>start</code> check-in shrinks the grace to the start delay.</p>
<h3>Write the Schedule From the Job</h3>
<p>An interval doesn't record when the job runs, so take the schedule from the crontab or scheduler. CronRadar reads five-field cron in UTC, with no seconds field, <code>@daily</code>-style shortcuts, <code>L</code>, <code>W</code>, <code>#</code>, <code>?</code>, or <code>7</code> for Sunday, and when both day fields are restricted it expects a run only on dates that match both (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>). Convert local times to UTC. If the scheduler follows daylight saving time, use the earlier of the two UTC times and a grace of at least an hour plus the job's usual duration. <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch vs CronRadar</a> has more conversions and covers jobs that run any time within a window.</p>
<h3>What Maps Across</h3>
<table>
<thead>
<tr>
<th>Opsgenie heartbeat</th>
<th>CronRadar monitor</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Monitor key in the check-in URL</td>
</tr>
<tr>
<td><code>interval</code> and <code>intervalUnit</code></td>
<td><code>schedule</code>, plus <code>gracePeriod</code> in seconds: 60 minimum, 90 by default, 60 to 86,400 in the monitor's settings</td>
</tr>
<tr>
<td><code>enabled: false</code>, or the disable request</td>
<td>Pause; a timed maintenance pause resumes monitoring on its own</td>
</tr>
<tr>
<td><code>alertPriority</code></td>
<td><strong>Severity</strong></td>
</tr>
<tr>
<td><code>ownerTeam</code></td>
<td><strong>Owner or team</strong>, and the application whose channels receive the alert</td>
</tr>
<tr>
<td><code>alertMessage</code>, <code>alertTags</code></td>
<td>No equivalent fields</td>
</tr>
<tr>
<td>Auto Heartbeat Creation</td>
<td>A check-in that carries <code>schedule</code> creates a missing monitor</td>
</tr>
<tr>
<td><code>PONG</code> for any name</td>
<td><code>404</code> with <code>MONITOR_NOT_FOUND</code> when the monitor doesn't exist and the check-in carries no <code>schedule</code></td>
</tr>
<tr>
<td>Email ping</td>
<td>An HTTP check-in from the job</td>
</tr>
<tr>
<td>Expiry alert, closed by the next ping</td>
<td><code>critical</code> and an incident: a first alert, at most one reminder every 24 hours, and a recovery message when a successful run resolves it</td>
</tr>
</tbody>
</table>
<p>A check-in needs <code>schedule</code> only to create a missing monitor (<a href="https://docs.cronradar.com/api-reference">API reference</a>), and CronRadar applies a check-in's <code>schedule</code> and <code>gracePeriod</code> only at that moment; change an existing monitor's values in its settings. The alert sequence and acknowledgment are in the <a href="https://docs.cronradar.com/verify">verification guide</a>.</p>
<p>An Opsgenie ping can't report a start or a failure; a CronRadar check-in can also be <code>start</code> or <code>fail</code>, and a <code>fail</code> opens an incident at once (<a href="https://docs.cronradar.com/concepts">concepts</a>). <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch vs CronRadar</a> covers the lifecycle events, stuck runs, and outcome values, and <a href="https://cronradar.com/blog/cron-job-alerting">cron job alerting</a> covers which of them deserve an alert.</p>
<h2>CronRadar Has No Opsgenie or Jira Service Management Channel</h2>
<p>CronRadar can't send alerts into Opsgenie or Jira Service Management. Its alert channels are email, Slack, webhook, Discord, and PagerDuty, connected per application in its <strong>Alerts</strong> tab. It has no on-call schedules, escalation policies, or SMS. To decide which migrated heartbeats belong on PagerDuty and which on Slack, email, or a ticket webhook, use the priority classes in the <a href="https://cronradar.com/methodology">CronRadar Scheduled Job Methodology</a>.</p>
<p>Jira Service Management's integration named Webhook doesn't close that gap: Atlassian documents it as "an outgoing integration" that posts Jira Service Management alert actions to a URL you give it (<a href="https://support.atlassian.com/jira-service-management-cloud/docs/integrate-with-webhook/">Integrate with Webhook</a>). It doesn't receive webhooks, and CronRadar's webhook payload hasn't been verified against any Jira Service Management incoming integration. If a heartbeat's alerts have to arrive in Jira Service Management, keep that heartbeat in Jira Service Management.</p>
<p>CronRadar's Free plan covers 10 active monitors with every alert channel and no card; beyond that, Grow bills each further monitor monthly by schedule class (<a href="https://cronradar.com/pricing">pricing</a>).</p>
<h2>Migration Checklist: Run Old and New in Parallel</h2>
<h3>1. Export the Heartbeat List</h3>
<p>The list request returns each heartbeat's name, interval, unit, enabled flag, owner team, and alert fields (<a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a>). It needs an API integration that isn't restricted to access configurations; a restricted one can only ping.</p>
<pre><code class="language-bash"># The integration's key, read from a file only this account can read.
OPSGENIE_API_KEY=$(cat /etc/opsgenie/api-key)
curl --fail --silent --show-error --max-time 10 \
  --header "Authorization: GenieKey $OPSGENIE_API_KEY" \
  https://api.opsgenie.com/v2/heartbeats |
  jq -r '.data.heartbeats[] | [.name, .interval, .intervalUnit, .enabled, (.ownerTeam.name // "-"), .alertPriority] | @tsv'
</code></pre>
<p>On a migrated site, list each team's heartbeats with <code>GET https://api.atlassian.com/jsm/ops/api/{cloudId}/v1/teams/{teamId}/heartbeats</code>, which returns <code>values</code> and a <code>links.next</code> page link (<a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-heartbeats/">Heartbeats API</a>). Then find the sender of each name in crontabs, scheduler configs, and scripts: <code>heartbeats/</code> URLs, <code>lamp heartbeat</code> calls, and the heartbeat's email address.</p>
<h3>2. Create Each Monitor Before the Parallel Run</h3>
<p>For each job, read its schedule and run time, then create the monitor before deploying the new check-in, with <code>POST /applications/{id}/jobs</code> on CronRadar's management API, sending the job's <code>name</code>, <code>monitorKey</code>, <code>schedule</code>, and <code>gracePeriod</code> (<a href="https://docs.cronradar.com/agent-quickstart">assistant access</a>). The Hangfire and Quartz.NET 3.x integrations create monitors when they discover jobs, so they need no separate step. A monitor created in advance waits in <code>pending</code>, and if the first expected run plus grace passes without a check-in, CronRadar sends a never-executed alert, so a wiring mistake such as a missing key file shows up instead of staying silent. A monitor created by its first check-in can't catch that.</p>
<p>Choose channels and recipients in the application's <strong>Alerts</strong> tab. A channel test proves a destination receives messages; a <code>fail</code> followed by a successful run, from a harmless job in a separate test application, proves detection, delivery, and recovery (<a href="https://docs.cronradar.com/verify">verification guide</a>).</p>
<h3>3. Send Both From the Same Job</h3>
<p>This wrapper is adapted from the nightly backup in <a href="https://cronradar.com/blog/cron-grace-period">choosing a grace period</a>, shown whole for copying, with the old Opsgenie ping added. It reports <code>start</code>, then <code>complete</code> or <code>fail</code>, to CronRadar, keeps the Opsgenie ping on success as before, and exits with the job's own code. Every request gives up after five seconds and ignores errors, so neither service can fail the backup. Both keys come from files only the job's account can read; <a href="https://cronradar.com/blog/cron-dead-mans-switch">the dead man's switch guide</a> explains why not to set them in the crontab.</p>
<pre><code class="language-bash">#!/bin/sh
# /opt/backup-monitored.sh
CRONRADAR_API_KEY=$(cat /etc/cronradar/api-key 2>/dev/null)
OPSGENIE_API_KEY=$(cat /etc/opsgenie/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/nightly-backup"
run_id="nightly-backup-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=30 2 * * *" \
    --data-urlencode "gracePeriod=900" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
/opt/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
  # The old heartbeat, kept for the parallel run (EU accounts: api.eu.opsgenie.com).
  # Delete this request at cutover.
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --header "Authorization: GenieKey $OPSGENIE_API_KEY" \
    "https://api.opsgenie.com/v2/heartbeats/nightly-backup/ping" || true
else
  report fail --data-urlencode "message=backup.sh exited with code $rc"
fi
exit "$rc"
</code></pre>
<pre><code class="language-text">30 2 * * * /opt/backup-monitored.sh
</code></pre>
<p>For a heartbeat already in Jira Service Management, swap the Opsgenie request for the Jira Service Management ping shown earlier. The check-ins carry the schedule, so a deleted monitor is recreated by the next run.</p>
<p>No CronRadar SDK is installable for Node.js, Python, Celery, PHP, or Laravel (checked September 20, 2026). Jobs there report with HTTP check-ins, which don't discover jobs automatically; <a href="https://cronradar.com/blog/node-js-cron-monitoring">Node.js cron monitoring</a> and <a href="https://cronradar.com/blog/celery-beat-not-running">Celery beat</a> have check-in helpers. On .NET, the published CronRadar 1.0.13 wraps a job, and CronRadar.Hangfire 0.1.0 and CronRadar.Quartz 0.1.0 (for Quartz.NET 3.x) discover recurring jobs; <a href="https://cronradar.com/blog/dead-mans-snitch-comparison">Dead Man's Snitch vs CronRadar</a> shows the setup and the Hangfire release's retry caveat.</p>
<h3>4. Overlap for the Job's Longest Gap</h3>
<p>Run both for at least one normal schedule window, and don't change the scheduler in the same deployment (<a href="https://docs.cronradar.com/migrations">migration guide</a>). Make the window cover the job's longest gap, a weekend for a weekday job and a full month for a monthly one, and confirm each scheduled run reached CronRadar with a result, not just a <code>start</code>.</p>
<h3>5. Disable, Then Remove the Ping, Then Delete</h3>
<p>A heartbeat whose ping is removed expires one interval later and alerts. Disable it first with <code>POST https://api.opsgenie.com/v2/heartbeats/:heartbeatName/disable</code>, deploy the wrapper without the old request, and then delete it with <code>DELETE https://api.opsgenie.com/v2/heartbeats/:heartbeatName</code> (<a href="https://docs.opsgenie.com/docs/heartbeat-api">Heartbeat API</a>). In Jira Service Management, send <code>"enabled": false</code> in a <code>PATCH</code> to <code>/v1/teams/{teamId}/heartbeats?name={name}</code>, and delete with a <code>DELETE</code> to the same URL (<a href="https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-heartbeats/">Heartbeats API</a>).</p>
<h3>6. Finish Before April 5, 2027</h3>
<p>For the heartbeats you keep in Jira Service Management, confirm the team-based configuration was activated in Opsgenie under <strong>Settings > General > Heartbeat Configuration > Activate Heartbeat</strong> (<a href="https://support.atlassian.com/opsgenie/docs/feature-changes-and-deprecations-in-jira-service-management/">feature changes</a>). Then work through <a href="#heartbeats-in-jira-service-management">the Jira Service Management changes</a> for each one: recreate it if it was v1, move its sender to the new email address or endpoint, and put an Atlassian API token on the host that pings.</p>
<h2>Frequently Asked Questions</h2>
<h3>When does Opsgenie shut down?</h3>
<p>Atlassian ended sales on June 4, 2025 and ends support on April 5, 2027. On that date, access is shut off and data that hasn't been migrated is deleted. Renewals after the end of sale are allowed only for terms that end before April 5, 2027.</p>
<h3>Do Opsgenie heartbeats migrate to Jira Service Management?</h3>
<p>Yes, they move automatically, but heartbeat monitoring works only on Jira Service Management Premium and Enterprise, not Free or Standard. Heartbeats on the old v1 version must be recreated by hand, email ping addresses change from opsgenie.net to atlassian.net, and API pings move to a team-scoped endpoint that authenticates with an Atlassian account's email and API token.</p>
<h3>How do I convert an Opsgenie heartbeat interval to a cron schedule?</h3>
<p>Take the schedule from the job, not the interval, and write it as five-field cron in UTC. Size the grace from the job's start delay plus, for a completion-only check-in, its run time. For a heartbeat sent when the job finishes, on a job with a fixed period and a steady run time, a grace of the interval minus the period plus the run time reproduces Opsgenie's alert time, but a grace sized from the job is usually shorter.</p>
<h3>Why didn't my Opsgenie heartbeat alert when a run was skipped?</h3>
<p>A heartbeat alerts only when the time since the last ping is longer than the interval. A 73-hour interval on a weekday job is long enough that skipping a Tuesday, Wednesday, or Thursday run leaves no gap longer than the interval, and a 10-minute interval on a 5-minute job usually misses a single skipped run. A cron schedule gives each run its own deadline, so a single skipped run alerts.</p>
<h3>Can CronRadar send alerts to Opsgenie or Jira Service Management?</h3>
<p>No. CronRadar's alert channels are email, Slack, webhook, Discord, and PagerDuty. Jira Service Management's Webhook integration is outgoing, so it doesn't accept CronRadar's webhook, and no other route into Jira Service Management has been verified.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>opsgenie heartbeat migration</category>
      <category>opsgenie end of support</category>
      <category>jira service management heartbeats</category>
      <category>opsgenie heartbeat api</category>
      <category>opsgenie heartbeat interval</category>
    </item>
    <item>
      <title><![CDATA[The Python schedule Library in Production: Exceptions, Threads, and Restarts]]></title>
      <link>https://cronradar.com/blog/python-schedule-library</link>
      <guid>https://cronradar.com/blog/python-schedule-library</guid>
      <description><![CDATA[How schedule 1.2.2 runs jobs: run_pending, why one exception stops the loop, serial runs, lost runs after restarts, .at() time zones, and threads.]]></description>
      <content:encoded><![CDATA[<p><strong>The <code>schedule</code> library runs a job only when your own loop calls <code>schedule.run_pending()</code>, one job at a time, in that loop's thread, and it keeps its jobs and their last run times only in memory.</strong> An exception from any job escapes <code>run_pending()</code> and ends the loop unless you catch it inside the job. A long job delays every job behind it, and a restart forgets everything, so a run that was due while the process was down never happens. In production, catch exceptions in a wrapper around each job, move long jobs into threads, run the process under a supervisor, and have each job check in with a monitor outside the process.</p>
<p>This guide covers schedule 1.2.2, the current release on <a href="https://pypi.org/project/schedule/1.2.2/">PyPI</a> (checked October 30, 2026). Source links point to the <a href="https://github.com/dbader/schedule/tree/1.2.2">1.2.2 tag</a>. For APScheduler, see <a href="https://cronradar.com/blog/python-scheduler-monitoring">Python scheduler monitoring</a>; for a script that system cron starts, see <a href="https://cronradar.com/blog/python-cron-job">Python script cron jobs</a>.</p>
<h2>How run_pending Decides What Runs</h2>
<p>Each job stores a <code>next_run</code> time. <code>run_pending()</code> collects the jobs whose <code>next_run</code> has arrived, sorts them by <code>next_run</code>, and calls each one in turn, waiting for it to return (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L89-L101"><code>run_pending</code></a>, <a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L666-L672"><code>should_run</code></a>). The library has no thread or timer of its own, and "out of the box it is not possible to run the schedule in the background" (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/background-execution.rst">background execution</a>), so nothing runs between calls. The docs' loop looks like this:</p>
<pre><code class="language-python">import time

import schedule

from jobs.tasks import rotate_logs

schedule.every().hour.at(":15").do(rotate_logs)

while True:
    schedule.run_pending()
    time.sleep(1)
</code></pre>
<p>Three details of that loop decide when your jobs actually run:</p>
<ul>
<li><strong>Missed runs collapse into one.</strong> The <a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L90-L98"><code>run_pending</code> docstring</a> calls it "intended behavior that run_pending() does not run missed jobs": a job due every minute, checked once an hour, runs once, not 60 times.</li>
<li><strong>Interval jobs count from the end of the last run.</strong> <code>Job.run()</code> calls your function, then computes the next run from the current time (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L674-L698"><code>Job.run</code></a>, <a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L700-L748"><code>_schedule_next_run</code></a>). <code>every(10).minutes</code> first runs ten minutes after the job is registered, and a job that takes three minutes then runs about every 13 minutes. The docs say so in bold: "Schedule does not account for the time it takes for the job function to execute" (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/index.rst">index</a>).</li>
<li><strong><code>.at()</code> jobs are anchored to the clock.</strong> <code>every().day.at("02:00")</code> runs at 02:00 each day however long the last run took.</li>
</ul>
<p><code>.at()</code> takes a format that depends on the unit, and if the format or unit doesn't fit, it raises <code>ScheduleValueError</code> as soon as it's called, before <code>.do()</code> registers the job (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L471-L559"><code>at</code></a>, <a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L644-L664"><code>do</code></a>):</p>
<table>
<thead>
<tr>
<th>Job</th>
<th><code>.at()</code> format</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>every().day</code>, or a weekday such as <code>.monday</code></td>
<td><code>HH:MM</code> or <code>HH:MM:SS</code></td>
<td><code>every().monday.at("09:30")</code></td>
</tr>
<tr>
<td><code>every().hour</code></td>
<td><code>:MM</code> or <code>MM:SS</code></td>
<td><code>every().hour.at(":15")</code></td>
</tr>
<tr>
<td><code>every().minute</code></td>
<td><code>:SS</code></td>
<td><code>every().minute.at(":17")</code></td>
</tr>
<tr>
<td><code>every().second</code>, <code>every().week</code></td>
<td>Not accepted</td>
<td>Raises <code>ScheduleValueError</code> at setup</td>
</tr>
</tbody>
</table>
<h2>One Exception Stops the Loop</h2>
<p>"Schedule doesn't catch exceptions that happen during job execution. Therefore any exceptions thrown during job execution will bubble up and interrupt schedule's run_xyz function" (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/exception-handling.rst">exception handling</a>). In the loop above, the exception leaves <code>while True</code>, the process exits with a traceback, and every other job stops with it.</p>
<p>Wrapping <code>schedule.run_pending()</code> itself in <code>try</code>/<code>except</code> is worse. The exception leaves <code>Job.run()</code> before it records the run and computes the next one (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L691-L693">lines 691-693</a>), so the failed job stays due. On the next pass, a second later, it sorts first because its <code>next_run</code> is the oldest, runs again, and raises again, and the jobs sorted after it never get their turn. While it keeps failing, an hourly job becomes a once-a-second job that blocks every other job.</p>
<p>Catch the exception inside the job instead, as the docs recommend. This is their <code>catch_exceptions</code> decorator with logging instead of <code>print</code> and <code>except Exception</code> instead of a bare <code>except</code>, so Ctrl-C and <code>sys.exit()</code> still stop the process (<a href="https://docs.python.org/3/library/exceptions.html#KeyboardInterrupt"><code>KeyboardInterrupt</code></a> and <a href="https://docs.python.org/3/library/exceptions.html#SystemExit"><code>SystemExit</code></a> inherit from <code>BaseException</code>, not <code>Exception</code>):</p>
<pre><code class="language-python"># jobs/runner.py
import functools
import logging

import schedule

log = logging.getLogger("jobs")


def catch_exceptions(cancel_on_failure=False):
    def decorator(job_func):
        @functools.wraps(job_func)
        def wrapper(*args, **kwargs):
            try:
                return job_func(*args, **kwargs)
            except Exception:
                log.exception("Scheduled job %s failed", job_func.__name__)
                if cancel_on_failure:
                    return schedule.CancelJob
        return wrapper
    return decorator
</code></pre>
<p>The wrapped job returns normally after a failure, so <code>Job.run()</code> schedules its next normal run and the loop moves on. With <code>cancel_on_failure=True</code>, the scheduler removes the job when it returns <code>CancelJob</code> (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L172-L175"><code>_run_job</code></a>), so one failure ends that job until the process restarts.</p>
<h2>Jobs Run One at a Time</h2>
<p>"By default, schedule executes all jobs serially" (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst">parallel execution</a>). While one job runs, <code>run_pending()</code> doesn't return and nothing else is checked. If a report due at 01:30 takes 40 minutes, a backup set for <code>.at("02:00")</code> starts no earlier than 02:10, and an <code>every(5).minutes</code> job that came due in between runs once, not eight times.</p>
<h3>Run Long Jobs in Threads</h3>
<p>The docs' workaround starts each run in its own thread:</p>
<pre><code class="language-python">import threading

import schedule

from jobs.tasks import sync_inventory


def run_threaded(job_func):
    job_thread = threading.Thread(target=job_func)
    job_thread.start()


schedule.every(10).minutes.do(run_threaded, sync_inventory)
</code></pre>
<p>That changes four things, all of which follow from the scheduler only seeing <code>run_threaded</code> return:</p>
<ul>
<li><strong>The loop stays on time.</strong> <code>run_pending()</code> returns as soon as the thread starts, so other jobs run when they're due, and an interval job's next run counts from when its thread started.</li>
<li><strong>An exception ends only its thread.</strong> Python passes it to <a href="https://docs.python.org/3/library/threading.html#threading.excepthook"><code>threading.excepthook</code></a>, which prints the traceback to stderr by default, and the loop keeps going. Keep <code>catch_exceptions</code> for a proper log line.</li>
<li><strong><code>CancelJob</code> stops working.</strong> The scheduler sees <code>run_threaded</code>'s return value, <code>None</code>, not your job's.</li>
<li><strong>Runs can overlap.</strong> A new thread starts every ten minutes whether or not the last one finished.</li>
</ul>
<p>When a job must not overlap itself, skip the new run while the previous one holds a lock. <a href="https://docs.python.org/3/library/threading.html#threading.Lock.acquire"><code>Lock.acquire(blocking=False)</code></a> returns <code>False</code> immediately instead of waiting:</p>
<pre><code class="language-python"># jobs/runner.py, continued
import threading


def run_threaded_once(job_func):
    """Start each run in a new thread, skipping it while the previous run is active."""
    lock = threading.Lock()

    def start():
        if not lock.acquire(blocking=False):
            log.warning("%s is still running; skipping this run", job_func.__name__)
            return

        def target():
            try:
                job_func()
            finally:
                lock.release()

        threading.Thread(target=target, name=job_func.__name__).start()

    return start
</code></pre>
<p>To cap the number of threads instead, the docs' second example puts jobs on a <code>queue.Queue</code> that one or more worker threads drain (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst">parallel execution</a>). To run the loop itself in a background thread of an existing application, see <a href="https://github.com/dbader/schedule/blob/1.2.2/docs/background-execution.rst">background execution</a>. Either way, each Python process has its own <code>default_scheduler</code> (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L837">line 837</a>), and nothing coordinates them, so a web app that starts the loop in each of four worker processes runs every job four times. Run the scheduler in one dedicated process; <a href="https://cronradar.com/blog/cron-job-running-twice">cron job running twice</a> covers the other ways a job gets doubled.</p>
<h2>A Restart Forgets Every Job</h2>
<p>Jobs live in a plain list in memory (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L86-L87"><code>Scheduler.__init__</code></a>), and <code>.do()</code> computes each job's first run from the moment it's called (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L644-L664"><code>do</code></a>). The docs list "Job persistence (remember schedule between restarts)" first among the reasons not to use schedule (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/index.rst">index</a>). After a crash, deploy, or reboot:</p>
<ul>
<li><strong>An <code>.at()</code> job skips the run it missed.</strong> A process that's down at 02:00 comes back with tomorrow's 02:00 as the next run, and today's never happens.</li>
<li><strong>An interval job starts its interval over.</strong> <code>every(6).hours</code> first runs six hours after the restart. A service deployed every four hours never runs it at all.</li>
<li><strong>Nothing catches up.</strong> There's no option to replay missed runs; <code>run_all()</code> runs every job immediately, whether or not it was due (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L103-L120"><code>run_all</code></a>).</li>
</ul>
<p>If a missed run must happen after an outage, the job has to remember its own last success somewhere durable and decide at startup. <a href="https://cronradar.com/blog/missed-runs-after-downtime">Missed runs after downtime</a> compares how other schedulers handle this.</p>
<h2>Time Zones and DST in .at()</h2>
<p>Without a time zone, <code>.at()</code> uses the host's local time: the next run is computed from the naive <code>datetime.datetime.now()</code> (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L717">line 717</a>). The same code runs at 02:00 UTC in a container set to UTC and at 02:00 Berlin time on a laptop set to Berlin. The docs add that jobs without a time zone "do <strong>not</strong> take clock-changes into account", so "a job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect" (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst">time zones</a>).</p>
<p><code>.at()</code> accepts a time zone as its second argument, as a string or a pytz time zone object:</p>
<pre><code class="language-python">schedule.every().day.at("02:00", "UTC").do(backup)
schedule.every().day.at("06:00", "America/New_York").do(send_digest)
</code></pre>
<ul>
<li><strong>Zones need pytz, an optional extra.</strong> schedule has no required dependencies and lists pytz under its <code>timezone</code> extra (<a href="https://github.com/dbader/schedule/blob/1.2.2/pyproject.toml">pyproject.toml</a>), so a plain <code>pip install schedule</code> doesn't install it; use <code>pip install "schedule[timezone]"</code> or <code>pip install pytz</code>. <code>.at()</code> imports pytz only when you pass a zone (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L498-L508">lines 498-508</a>), and without it that <code>.at()</code> call raises <code>ModuleNotFoundError: No module named 'pytz'</code> (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/faq.rst">FAQ</a>).</li>
<li><strong>Only <code>.at()</code> takes a zone.</strong> <code>next_run</code> and <code>last_run</code> are stored as naive times in the process's local zone either way (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst">time zones</a>).</li>
<li><strong>DST is handled when a zone is given.</strong> For <code>.at("02:30", "Europe/Berlin")</code>, on the night the clock skips from 02:00 to 03:00 the job runs once at 03:30; on the night 02:30 happens twice, it runs only the first time (<a href="https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst">time zones</a>).</li>
<li><strong>Use 1.2.2 if you pass a zone.</strong> Zones arrived in 1.2.0; 1.2.1 fixed "schedule was off when using .at with timezone", and 1.2.2 fixed more cross-timezone bugs (<a href="https://github.com/dbader/schedule/blob/1.2.2/HISTORY.rst">HISTORY</a>).</li>
</ul>
<p><code>.at("02:00", "UTC")</code> runs at the same instant on any host and matches a UTC cron schedule exactly. <a href="https://cronradar.com/blog/cron-timezone">Cron timezone</a> covers when a local zone is really what you want.</p>
<h2>Keep the Process Alive With a Supervisor</h2>
<p>The loop is the scheduler. If the process exits, from an uncaught exception, an out-of-memory kill, or a reboot, nothing runs and nothing logs that it didn't. Run it as a service that restarts it. With systemd:</p>
<pre><code class="language-ini"># /etc/systemd/system/jobs.service
[Unit]
Description=Scheduled jobs (schedule library)
Wants=network-online.target
After=network-online.target

[Service]
User=jobs
WorkingDirectory=/srv/jobs
EnvironmentFile=/etc/jobs/jobs.env
ExecStart=/srv/jobs/.venv/bin/python -u -m jobs.scheduler
Restart=always
RestartSec=5s

[Install]
WantedBy=multi-user.target
</code></pre>
<p><code>Restart=always</code> restarts the service "regardless of whether it exited cleanly or not"; the default is <code>no</code> (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html">systemd.service</a>). <code>EnvironmentFile=</code> reads variables such as <code>CRONRADAR_API_KEY</code> from a file (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#EnvironmentFile=">systemd.exec</a>), which keeps the key out of the unit and the repository; make that file readable only by root. <a href="https://docs.python.org/3/using/cmdline.html#cmdoption-u"><code>-u</code></a> unbuffers stdout for any <code>print()</code> output from your jobs; <a href="https://cronradar.com/blog/python-cron-job">Python script cron jobs</a> explains the buffering. <a href="https://cronradar.com/blog/cron-reboot">Cron @reboot</a> covers <code>network-online.target</code>, the start limit, and <code>OnFailure=</code> for the same kind of service.</p>
<p>On stop or restart, systemd sends SIGTERM, then SIGKILL if the process is still there after a timeout (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html">systemd.service</a>). Python installs default handlers only for SIGPIPE and SIGINT (<a href="https://docs.python.org/3/library/signal.html#general-rules">signal</a>), so SIGTERM ends the process, cutting off whatever job is running. <a href="https://cronradar.com/blog/python-cron-job">Python script cron jobs</a> shows how to turn SIGTERM into a clean exit. A supervisor brings the loop back, but because of the restart behavior above, it doesn't bring back the run that was due while the process was down.</p>
<h2>Report Each Run With an HTTP Check-In</h2>
<p>None of the failures above alerts anyone: a dead loop leaves at most a traceback in the journal, and a run skipped after a restart or a job blocked behind a long one leaves nothing at all. A check-in from each run turns that silence into an alert from outside the process.</p>
<p>There's no installable CronRadar SDK for Python today; the <a href="https://docs.cronradar.com/integrations/python">Python integration docs</a> list Python SDK connections as temporarily unavailable. Each job reports over HTTP, and HTTP check-ins don't discover jobs automatically: nothing reads <code>schedule.get_jobs()</code>, so give each job its own monitor key and a real schedule. This standard-library helper sends <code>start</code>, then <code>complete</code> or <code>fail</code>, with Basic authentication, a five-second timeout, and one <code>run_id</code> for both events. It logs monitoring errors instead of raising them and re-raises the job's own exception:</p>
<pre><code class="language-python"># jobs/cronradar.py
import base64
import functools
import logging
import os
import urllib.parse
import urllib.request
import uuid

log = logging.getLogger(__name__)


def _check_in(monitor_key, event, params):
    query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
    key = urllib.parse.quote(monitor_key, safe="")
    url = f"https://cron.life/api/v1/ping/{key}/{event}?{query}"
    try:
        token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
        request = urllib.request.Request(url, data=b"", method="POST")
        # Unlike add_header(), this header is not copied to a redirect.
        request.add_unredirected_header("Authorization", f"Basic {token}")
        with urllib.request.urlopen(request, timeout=5):
            pass
    except Exception as error:
        # A monitoring problem must never fail the job or change its result.
        log.warning("CronRadar %s for %s not recorded (%s)", event, monitor_key, type(error).__name__)


def reported(monitor_key, cron_schedule, grace_period=None):
    """Report start, then complete or fail, for each run. Re-raises the job's exception."""
    def decorator(job_func):
        @functools.wraps(job_func)
        def wrapper(*args, **kwargs):
            params = {"schedule": cron_schedule, "run_id": uuid.uuid4().hex}
            if grace_period is not None:
                params["gracePeriod"] = grace_period
            _check_in(monitor_key, "start", params)
            try:
                result = job_func(*args, **kwargs)
            except Exception as error:
                # The exception type only: never arguments, payloads, or messages.
                _check_in(monitor_key, "fail", {**params, "message": type(error).__name__})
                raise
            _check_in(monitor_key, "complete", params)
            return result
        return wrapper
    return decorator
</code></pre>
<p>The scheduler module combines the three pieces. <code>reported</code> sits innermost so it sees the exception; <code>catch_exceptions</code> logs it with a traceback through <code>logging</code> instead of leaving it to <code>threading.excepthook</code>'s stderr print; <code>run_threaded_once</code> keeps a long backup from holding up the sync. The sync runs on the hour's ten-minute marks instead of <code>every(10).minutes</code>, so it matches <code>*/10 * * * *</code> instead of drifting away from it:</p>
<pre><code class="language-python"># jobs/scheduler.py
import logging
import time

import schedule

from jobs.cronradar import reported
from jobs.runner import catch_exceptions, run_threaded_once
from jobs.tasks import backup_database, sync_inventory

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")


def monitored(job_func, monitor_key, cron_schedule, grace_period):
    """Report each run, log its exception, and run it in its own thread."""
    reported_job = reported(monitor_key, cron_schedule, grace_period)(job_func)
    return run_threaded_once(catch_exceptions()(reported_job))


backup = monitored(backup_database, "nightly-backup", "0 2 * * *", grace_period=600)
schedule.every().day.at("02:00", "UTC").do(backup)

sync = monitored(sync_inventory, "inventory-sync", "*/10 * * * *", grace_period=300)
for minute in range(0, 60, 10):
    schedule.every().hour.at(f":{minute:02d}").do(sync)

if __name__ == "__main__":
    while True:
        schedule.run_pending()
        time.sleep(1)
</code></pre>
<p>The six sync jobs share one <code>sync</code> callable, so they share one lock. The first check-in with a <code>schedule</code> creates the monitor, and sending it on every check-in recreates a deleted one on the next run (<a href="https://docs.cronradar.com/api-reference">HTTP API</a>). <code>schedule</code> and <code>gracePeriod</code> apply only when a check-in creates the monitor; change them later in its settings.</p>
<h3>What CronRadar Sees for Each Failure</h3>
<table>
<thead>
<tr>
<th>What happened</th>
<th>Check-ins</th>
<th>CronRadar</th>
</tr>
</thead>
<tbody>
<tr>
<td>The job raised</td>
<td><code>start</code>, then <code>fail</code></td>
<td>Result <code>failed</code>, monitor <code>critical</code>, incident opened immediately</td>
</tr>
<tr>
<td>Loop dead, process down, or the run lost to a restart</td>
<td>None</td>
<td><code>warning</code> after the expected time; <code>critical</code> with result <code>timeout</code> once past the grace period and at least two minutes late</td>
</tr>
<tr>
<td>A run skipped because the previous one still holds the lock</td>
<td>None for that mark</td>
<td><code>warning</code> after the skipped mark; <code>healthy</code> again if the earlier run completes within the grace period, otherwise <code>critical</code>, shown as stuck because that run's <code>start</code> is still open</td>
</tr>
<tr>
<td>The job hung</td>
<td><code>start</code> only</td>
<td><code>critical</code> as stuck, but only once the next scheduled time plus the grace period passes with no check-in, since the lock skips the runs after it; for the nightly backup, that's the next night</td>
</tr>
<tr>
<td>The process was killed mid-run and the supervisor restarted it</td>
<td><code>start</code> only for that run</td>
<td>Not flagged if the next run reports normally: its <code>start</code> moves the expected time on, and there is no maximum-runtime setting</td>
</tr>
<tr>
<td>The job returned but did nothing</td>
<td><code>start</code>, then <code>complete</code></td>
<td><code>success</code>, unless you report outcome values and configure checks on them</td>
</tr>
</tbody>
</table>
<p>Alerts go to email, Slack, webhook, Discord, or PagerDuty. For the last row, <a href="https://cronradar.com/blog/scheduled-job-silent-success">scheduled job silent success</a> shows how to send counts with <code>complete</code>, so an empty run is recorded as an outcome failure (<code>outcomefailed</code>) and opens an incident.</p>
<h3>Translate Each Job to a UTC Cron Schedule</h3>
<p>CronRadar reads <code>schedule</code> as standard five-field cron evaluated in UTC (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>), and after each check-in it expects the next run at the first cron time following the latest scheduled time at or before that check-in. A job that runs on the schedule's marks fits with a short grace period; one that drifts needs a long one.</p>
<table>
<thead>
<tr>
<th>schedule job</th>
<th><code>schedule</code> to send</th>
<th>Grace period</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>every().day.at("02:00", "UTC")</code></td>
<td><code>0 2 * * *</code></td>
<td>A few minutes</td>
</tr>
<tr>
<td><code>every().monday.at("09:30", "UTC")</code></td>
<td><code>30 9 * * 1</code></td>
<td>A few minutes</td>
</tr>
<tr>
<td><code>every().hour.at(":15")</code></td>
<td><code>15 * * * *</code></td>
<td>A few minutes, if the host's UTC offset is a whole number of hours</td>
</tr>
<tr>
<td><code>every().day.at("06:00", "America/New_York")</code></td>
<td><code>0 10 * * *</code></td>
<td>At least an hour plus the run's usual duration</td>
</tr>
<tr>
<td><code>every(10).minutes</code></td>
<td><code>*/10 * * * *</code></td>
<td>At least the interval plus the run's duration; better, run on <code>.at()</code> marks</td>
</tr>
<tr>
<td><code>every(5).to(10).minutes</code></td>
<td>None</td>
<td>Randomized intervals (<a href="https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L561-L574"><code>to</code></a>) have no cron equivalent</td>
</tr>
</tbody>
</table>
<p>The New York row follows CronRadar's daylight-saving rule: send the earlier of the two UTC times, 10:00 during daylight time, with a grace period of at least an hour plus the run's duration. In winter the job runs at 11:00 UTC, and the monitor shows <code>warning</code> each day from 10:00 until that run completes. For how grace periods interact with run time, see <a href="https://cronradar.com/blog/cron-grace-period">cron grace period</a>.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why did my schedule loop stop running jobs?</h3>
<p>Most often a job raised an exception, which schedule doesn't catch: it escapes <code>run_pending()</code> and ends the <code>while True</code> loop. Wrap each job in a decorator that catches and logs exceptions, as the <a href="https://github.com/dbader/schedule/blob/1.2.2/docs/exception-handling.rst">exception handling docs</a> show, and run the process under a supervisor such as systemd with <code>Restart=always</code>.</p>
<h3>Does the schedule library run missed jobs after a restart?</h3>
<p>No. Jobs exist only in memory, and each job's first run is computed when <code>.do()</code> is called, so a run that was due while the process was down never happens. An <code>.at()</code> job waits for its next time; an interval job waits a full interval from the restart.</p>
<h3>How do I run schedule jobs in parallel?</h3>
<p>Start each run in its own thread, as the <a href="https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst">parallel execution docs</a> show, or put jobs on a queue that worker threads drain. Threaded runs can overlap, so use a lock when a job must not run twice at once.</p>
<h3>How do I set a time zone in Python schedule?</h3>
<p>Pass it as the second argument to <code>.at()</code>, such as <code>every().day.at("02:00", "Europe/Berlin")</code>. It requires pytz, which a plain <code>pip install schedule</code> doesn't install; use <code>pip install "schedule[timezone]"</code>. Zones are available only in <code>.at()</code>. Without a zone, <code>.at()</code> uses the host's local time.</p>
<h3>Is there a CronRadar SDK for the schedule library?</h3>
<p>No. Python SDK connections are temporarily unavailable, so each job reports with an HTTP check-in, and nothing discovers schedule jobs automatically. Give each job its own monitor key and a UTC cron schedule that matches when it runs.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>python schedule library</category>
      <category>schedule run_pending</category>
      <category>python schedule exception handling</category>
      <category>python schedule timezone</category>
      <category>python schedule run in thread</category>
    </item>
    <item>
      <title><![CDATA[Quartz.NET Cron Expressions: Seven Fields, L, W, #, and Time Zones]]></title>
      <link>https://cronradar.com/blog/quartz-net-cron-expressions</link>
      <guid>https://cronradar.com/blog/quartz-net-cron-expressions</guid>
      <description><![CDATA[Quartz.NET 3.x cron reference: seconds-first fields, the ? rule, L, W, LW, L-n, and #, CronScheduleBuilder strings, InTimeZone, DST, and five-field UTC.]]></description>
      <content:encoded><![CDATA[<p><strong>A Quartz.NET cron expression has six required fields, seconds first, and an optional seventh field for the year. Exactly one of day of month and day of week must be <code>?</code>, so daily at 02:30 is <code>0 30 2 * * ?</code> and weekdays at 09:00 is <code>0 0 9 ? * MON-FRI</code>.</strong> A trigger with no time zone runs in <code>TimeZoneInfo.Local</code>, so name the zone with <code>InTimeZone</code>.</p>
<p>This guide covers Quartz.NET 3.22.9, the current 3.x release on <a href="https://www.nuget.org/packages/Quartz/3.22.9">NuGet</a> (checked October 30, 2026). Every parsing rule below is from <a href="https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronExpression.cs">CronExpression.cs</a> at that tag. Quartz.NET 4.x is also on <a href="https://www.nuget.org/packages/Quartz/4.6.2">NuGet</a> (4.6.2, checked October 30, 2026); the daylight saving section notes where 4.x behaves differently, and CronRadar.Quartz 0.1.0 targets 3.x. For cron syntax in general, see <a href="https://cronradar.com/blog/cron-expression">cron expression syntax</a>. For the same reference on Hangfire, see <a href="https://cronradar.com/blog/hangfire-cron-time-zones">Hangfire cron expressions</a>.</p>
<h2>The Seven Fields, Seconds First</h2>
<pre><code class="language-text">┌───────────── second (0-59)
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31; also ? L W)
│ │ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌───────────── day of week (1-7 or SUN-SAT; 1 is Sunday; also ? L #)
│ │ │ │ │ │ ┌───────────── year (optional)
│ │ │ │ │ │ │
0 30 2 * * ? *
</code></pre>
<p>The class documentation lists "6 required fields and one optional field". Points from the source that catch people coming from Unix cron:</p>
<ul>
<li><strong>Seconds come first.</strong> A five-field Unix expression such as <code>30 2 * * *</code> fills only five fields, and the parser throws <code>Unexpected end of expression.</code>. More than seven fields throws <code>Invalid expression has too many terms: </code> followed by the expression.</li>
<li><strong>Day of week runs 1 to 7, with 1 as Sunday.</strong> In Unix cron and CronRadar, Sunday is 0 and Monday is 1. Names avoid the off-by-one: <code>MON-FRI</code> means the same thing in both.</li>
<li><strong>Steps start from a value.</strong> <code>0/15</code> in the minute field means 0, 15, 30, and 45, and <code>*</code> before <code>/</code> is the same as starting at 0. The docs warn that <code>7/6</code> in the month field "only turns on month 7, it does NOT mean every 6th month".</li>
<li><strong>Names and special characters aren't case-sensitive</strong>, and wrapping ranges such as <code>22-2</code> or <code>NOV-FEB</code> are supported.</li>
<li><strong>The year field</strong> defaults to every year when you leave it out. The parser fills it with <code>*</code>. The class documentation gives its range as 1970 to 2199, but 3.22.9 returns no fire time after <code>MaxYear</code>, the current year plus 100, so in 2026 a year after 2126 never fires.</li>
</ul>
<p>Quartz.NET 3.22.9 also accepts <code>H</code> (hash) tokens, which <a href="https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronScheduleBuilder.cs">CronScheduleBuilder.cs</a> resolves from the trigger's identity. They don't exist in five-field cron, so they are out of scope here.</p>
<h2>Why One Day Field Must Be ?</h2>
<p><code>?</code> means "no specific value" and is allowed only in the day-of-month and day-of-week fields. After reading the fields, <code>BuildExpression</code> checks that exactly one of them is <code>?</code>. If neither is, or both are, it throws:</p>
<pre><code class="language-text">Support for specifying both a day-of-week AND a day-of-month parameter is not implemented.
</code></pre>
<p>That rules out the most common conversion mistake. <code>0 0 2 * * *</code>, a six-field version of <code>0 2 * * *</code>, restricts neither day field with <code>?</code> and fails, and so does <code>0 0 2 ? * ?</code>. Put <code>?</code> in the field you aren't using:</p>
<table>
<thead>
<tr>
<th>You mean</th>
<th>Wrong</th>
<th>Right</th>
</tr>
</thead>
<tbody>
<tr>
<td>Every day at 02:00</td>
<td><code>0 0 2 * * *</code></td>
<td><code>0 0 2 * * ?</code></td>
</tr>
<tr>
<td>Weekdays at 09:00</td>
<td><code>0 0 9 * * MON-FRI</code></td>
<td><code>0 0 9 ? * MON-FRI</code></td>
</tr>
<tr>
<td>15th at 06:00</td>
<td><code>0 0 6 15 * *</code></td>
<td><code>0 0 6 15 * ?</code></td>
</tr>
</tbody>
</table>
<p>Because Quartz never restricts both day fields at once, it has no equivalent of Unix cron's "either day matches" rule, or of the "both must match" rule that <a href="https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md">Cronos</a> and CronRadar use. "Friday the 13th" can't be written as one Quartz expression.</p>
<p>Two more day-of-week checks throw before that: <code>L</code> combined with a list (<code>Support for specifying 'L' with other days of the week is not implemented</code>), and more than one <code>#</code> (<code>Support for specifying multiple "nth" days is not implemented.</code>).</p>
<h2>L, W, LW, L-n, and <code>#</code></h2>
<table>
<thead>
<tr>
<th>Expression</th>
<th>Fires at</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0 0 23 L * ?</code></td>
<td>23:00 on the last day of the month</td>
</tr>
<tr>
<td><code>0 0 23 L-3 * ?</code></td>
<td>23:00 on what the docs call "the third-to-last day"</td>
</tr>
<tr>
<td><code>0 0 9 15W * ?</code></td>
<td>09:00 on the weekday nearest the 15th</td>
</tr>
<tr>
<td><code>0 0 18 LW * ?</code></td>
<td>18:00 on the last weekday of the month</td>
</tr>
<tr>
<td><code>0 0 9 ? * 6L</code></td>
<td>09:00 on the last Friday of the month</td>
</tr>
<tr>
<td><code>0 0 9 ? * L</code></td>
<td>09:00 every Saturday (<code>L</code> alone means <code>7</code>, <code>SAT</code>)</td>
</tr>
<tr>
<td><code>0 0 9 ? * 2#1</code></td>
<td>09:00 on the first Monday of the month</td>
</tr>
<tr>
<td><code>0 0 9 ? * 6#3</code></td>
<td>09:00 on the third Friday of the month</td>
</tr>
</tbody>
</table>
<p>The rules, from the class documentation and the parser:</p>
<ul>
<li><strong><code>L</code> means something different in each day field.</strong> In day of month it is the last day: 31 in January, 28 in February in a non-leap year. In day of week, by itself it is just Saturday; after a day number, <code>6L</code> is the last Friday. The docs warn against combining <code>L</code> with lists or ranges, "as you'll get confusing/unexpected results".</li>
<li><strong><code>L-n</code> counts back from the last day.</strong> The offset must be 30 or less, or the parser throws <code>Offset from last day must be &#x3C;= 30</code>. 3.22.9 also parses <code>L-nW</code> and <code>LW-n</code>.</li>
<li><strong><code>W</code> moves to the nearest weekday but stays in the month.</strong> If the 15th is a Saturday, <code>15W</code> fires Friday the 14th; if it is a Sunday, Monday the 16th. <code>1W</code> on a Saturday the 1st fires Monday the 3rd, because it won't jump back into the previous month. The class documentation says <code>W</code> works only on a single day, not a range or list, but the 3.22.9 parser tracks each <code>nW</code> entry separately, so a list such as <code>1W,15W</code> parses and each entry shifts its own day.</li>
<li><strong><code>#</code> takes 1 to 5.</strong> Anything else throws <code>A numeric value between 1 and 5 must follow the '#' option</code>. A fifth occurrence that doesn't exist that month means "no firing will occur that month", and only one <code>#</code> is allowed in the field: <code>3#1,6#3</code> is invalid.</li>
</ul>
<p>For the five-field workaround for last-day schedules, see <a href="https://cronradar.com/blog/cron-last-day-of-month">running cron on the last day of the month</a>.</p>
<h2>Common Schedules as Quartz Strings</h2>
<table>
<thead>
<tr>
<th>Schedule</th>
<th>Quartz expression</th>
<th>Five-field equivalent</th>
</tr>
</thead>
<tbody>
<tr>
<td>Every 30 seconds</td>
<td><code>0/30 * * * * ?</code></td>
<td>None (seconds)</td>
</tr>
<tr>
<td>Every 5 minutes</td>
<td><code>0 0/5 * * * ?</code></td>
<td><code>*/5 * * * *</code></td>
</tr>
<tr>
<td>Hourly at :15</td>
<td><code>0 15 * * * ?</code></td>
<td><code>15 * * * *</code></td>
</tr>
<tr>
<td>Daily at 02:30</td>
<td><code>0 30 2 * * ?</code></td>
<td><code>30 2 * * *</code></td>
</tr>
<tr>
<td>Weekdays at 09:00</td>
<td><code>0 0 9 ? * MON-FRI</code></td>
<td><code>0 9 * * 1-5</code></td>
</tr>
<tr>
<td>15th of the month at 06:00</td>
<td><code>0 0 6 15 * ?</code></td>
<td><code>0 6 15 * *</code></td>
</tr>
<tr>
<td>First Monday of the month at 09:00</td>
<td><code>0 0 9 ? * 2#1</code></td>
<td><code>0 9 1-7 * 1</code> (CronRadar only)</td>
</tr>
<tr>
<td>Last day of the month at 23:00</td>
<td><code>0 0 23 L * ?</code></td>
<td>None (<code>L</code>)</td>
</tr>
<tr>
<td>Every day in 2027 at 02:30</td>
<td><code>0 30 2 * * ? 2027</code></td>
<td>None (year)</td>
</tr>
</tbody>
</table>
<p><code>0 9 1-7 * 1</code> means the first Monday only to a parser that requires both day fields to match, which CronRadar does (<a href="https://docs.cronradar.com/schedules">schedules</a>). Linux cron runs it on the 1st through the 7th and on every Monday.</p>
<h2>CronScheduleBuilder Helpers and the Strings They Build</h2>
<p>The helpers in <a href="https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronScheduleBuilder.cs">CronScheduleBuilder.cs</a> build a string with <a href="https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/CronExpressionBuilder.cs">CronExpressionBuilder</a>, which renders an unset day of month as <code>?</code>, an unset day of week as <code>?</code> when a day of month is set and <code>*</code> otherwise, and day-of-week values as <code>SUN</code> through <code>SAT</code>:</p>
<table>
<thead>
<tr>
<th>Helper</th>
<th>Expression</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>DailyAtHourAndMinute(2, 30)</code></td>
<td><code>0 30 2 ? * *</code></td>
</tr>
<tr>
<td><code>WeeklyOnDayAndHourAndMinute(DayOfWeek.Friday, 17, 30)</code></td>
<td><code>0 30 17 ? * FRI</code></td>
</tr>
<tr>
<td><code>AtHourAndMinuteOnGivenDaysOfWeek(9, 0, DayOfWeek.Monday, DayOfWeek.Wednesday)</code></td>
<td><code>0 0 9 ? * MON,WED</code></td>
</tr>
<tr>
<td><code>MonthlyOnDayAndHourAndMinute(15, 6, 0)</code></td>
<td><code>0 0 6 15 * ?</code></td>
</tr>
</tbody>
</table>
<p><code>CronScheduleBuilder.CronSchedule(string)</code> validates the expression as soon as you call it, through <code>CronExpression.ValidateExpression</code>, which constructs a <code>CronExpression</code>. A bad string throws <code>FormatException</code> with one of the messages above while you build the trigger, not at fire time. The helpers check their arguments first and throw <code>ArgumentException</code> for an hour, minute, or day out of range.</p>
<h2>Set the Time Zone With InTimeZone</h2>
<p>A cron trigger whose time zone was never set reports <code>TimeZoneInfo.Local</code> (<a href="https://github.com/quartznet/quartznet/blob/v3.22.9/src/Quartz/Impl/Triggers/CronTriggerImpl.cs">CronTriggerImpl.cs</a>); <a href="https://cronradar.com/blog/quartz-net-job-not-firing">why a Quartz.NET job is not firing</a> covers debugging a trigger that runs at the host's local time. <code>InTimeZone</code> sets the zone on the builder's <code>CronExpression</code>, and <code>Build()</code> copies it onto the trigger:</p>
<pre><code class="language-csharp">using Quartz;

IJobDetail job = JobBuilder.Create&#x3C;NightlyReportJob>()
    .WithIdentity("nightly-report", "reports")
    .Build();

// 02:30 UTC every day, whatever the host's zone.
ITrigger utcTrigger = TriggerBuilder.Create()
    .WithIdentity("nightly-report-utc", "reports")
    .ForJob(job)
    .WithSchedule(CronScheduleBuilder
        .CronSchedule("0 30 2 * * ?")
        .InTimeZone(TimeZoneInfo.Utc))
    .Build();

// 09:00 New York time on weekdays, following daylight saving time.
ITrigger localTrigger = TriggerBuilder.Create()
    .WithIdentity("weekday-digest-ny", "reports")
    .ForJob(job)
    .WithSchedule(CronScheduleBuilder
        .CronSchedule("0 0 9 ? * MON-FRI")
        .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York")))
    .Build();
</code></pre>
<p>The zone ID has to resolve on every host that runs the scheduler; the <a href="https://cronradar.com/blog/hangfire-cron-time-zones">Hangfire guide</a> has a table of which .NET runtimes resolve IANA and Windows IDs. Missed fire times, including the ones a restart causes, are handled by the misfire instruction rather than the expression: see <a href="https://cronradar.com/blog/quartz-net-misfire-instructions">Quartz.NET misfire instructions</a>.</p>
<h2>Daylight Saving Time in Quartz.NET 3.x</h2>
<p>The <a href="https://www.quartz-scheduler.net/documentation/faq.html#daylight-saving-time-and-triggers">FAQ</a> says a cron trigger "is never skipped by a transition, and never fires twice for one scheduled occurrence", and splits expressions into two kinds. A fixed-time expression has plain values or lists in the second, minute, and hour fields, such as <code>0 15 2 * * ?</code>. An interval expression has a wildcard, step, or range in one of them.</p>
<table>
<thead>
<tr>
<th>Transition</th>
<th>Expression type</th>
<th>3.x</th>
<th>4.x</th>
</tr>
</thead>
<tbody>
<tr>
<td>Spring forward, time missing</td>
<td>Fixed time</td>
<td>Fires once, shifted forward by the gap: 02:15 becomes 03:15</td>
<td>Fires once at the end of the gap, 03:00</td>
</tr>
<tr>
<td>Fall back, time repeated</td>
<td>Fixed time</td>
<td>Fires once, at the first occurrence</td>
<td>Same as 3.x</td>
</tr>
<tr>
<td>Fall back, hour repeated</td>
<td>Interval</td>
<td>Fires the repeated hour once; an hour passes with no firing</td>
<td>Fires in both passes</td>
</tr>
<tr>
<td>Spring forward, <code>0 30 * * * ?</code></td>
<td>Interval</td>
<td>Resumes at the shifted 03:30 and fires once</td>
<td>Fires at 03:00 and again at 03:30</td>
</tr>
</tbody>
</table>
<p>For <code>0 30 2 * * ?</code> in <code>America/New_York</code>, clocks jump from 02:00 EST to 03:00 EDT on March 14, 2027. Quartz 3.x fires at 02:30 EST (07:30 UTC) on March 13, at 03:30 EDT (07:30 UTC) on March 14, and at 02:30 EDT (06:30 UTC) on March 15. The local run time is fixed and the UTC time moves, which is what anything watching in UTC sees. <a href="https://cronradar.com/blog/cron-daylight-saving-time">Cron and daylight saving time</a> covers the general problem.</p>
<h2>Translating a Quartz Expression to Five-Field UTC</h2>
<p>CronRadar reads schedules as standard five-field cron evaluated in UTC. Its <a href="https://docs.cronradar.com/schedules">schedule docs</a> reject a seconds field, a year field, <code>?</code>, <code>L</code>, <code>W</code>, <code>#</code>, shortcuts such as <code>@daily</code>, and <code>7</code> for Sunday. To rewrite a Quartz expression:</p>
<ol>
<li><strong>Drop the seconds field.</strong> It must be a single value. A step or list there, such as <code>0/30</code>, runs more than once a minute and has no five-field equivalent.</li>
<li><strong>Drop the year field.</strong> A restricted year has no equivalent.</li>
<li><strong>Replace <code>?</code> with <code>*</code>.</strong></li>
<li><strong>Fix day-of-week numbers.</strong> Subtract 1 (Quartz <code>2-6</code> is <code>1-5</code>), or keep the names.</li>
<li><strong>Rewrite <code>L</code>, <code>W</code>, <code>LW</code>, <code>L-n</code>, and <code>#</code>.</strong> There is no five-field form. Schedule the job on every candidate date, such as <code>28-31</code> for the last day, and let the job decide whether to do the work, checking in on every run, including the ones that exit early. <code>2#1</code> can become <code>1-7</code> in day of month with <code>1</code> in day of week, but only for a parser that matches both fields.</li>
<li><strong>Convert the hours to UTC.</strong> With <code>InTimeZone(TimeZoneInfo.Utc)</code> there is nothing to do. For a zone with daylight saving time, the schedule docs say to use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration: for 02:30 New York, <code>30 6 * * *</code> with at least 3,600 seconds of grace.</li>
</ol>
<p>Minute and hour steps carry over unchanged: CronRadar accepts <code>0/5</code> as well as <code>*/5</code>.</p>
<p><strong>On CronRadar.Quartz 0.1.0, make that translation yourself.</strong> The published package sends a cron trigger's <code>CronExpressionString</code> unchanged, from both its startup reconciliation and its job listener's start signal (<a href="https://github.com/cronradar/cronradar-quartz/blob/v0.1.0/src/CronRadar.Quartz/CronRadarQuartzSchedule.cs">CronRadarQuartzSchedule.cs</a>). Every Quartz cron expression has a seconds field, so CronRadar rejects that schedule: discovery sync lists the monitor in <code>errors</code>, and a check-in carrying it gets <code>400 INVALID_REQUEST</code> with <code>Invalid schedule format</code>. The conversion described in the <a href="https://docs.cronradar.com/integrations/quartz">Quartz integration guide</a> belongs to the pending 0.1.1 release. 0.1.0 has no schedule override: <code>[CronRadar]</code> sets only the monitor key (<a href="https://github.com/cronradar/cronradar-quartz/blob/v0.1.0/src/CronRadar.Quartz/CronRadarAttribute.cs">CronRadarAttribute.cs</a>). Until 0.1.1 ships, mark a cron-triggered job <code>[SkipMonitor]</code>, which keeps both the reconciliation and the job listener away from it, and report the job yourself with its five-field UTC schedule: through <code>WrapAsync</code> in the base <a href="https://www.nuget.org/packages/CronRadar/1.0.13">CronRadar</a> 1.0.13 SDK, which takes the schedule as its <code>schedule</code> argument (<a href="https://github.com/cronradar/cronradar-dotnet/blob/v1.0.13/src/CronRadar/CronRadar.cs">CronRadar.cs</a>), or through <a href="https://docs.cronradar.com/api-reference">HTTP check-ins</a> with Basic auth. Simple triggers that repeat are converted to an approximate five-field schedule in 0.1.0 already.</p>
<p>Once the schedule is in place, CronRadar marks a job <code>warning</code> when its run is late and <code>critical</code> when the grace period passes without one or the run reports <code>failed</code>, and alerts by email, Slack, webhook, Discord, or PagerDuty. <a href="https://cronradar.com/blog/quartz-net-monitoring">Quartz.NET monitoring</a> covers what to watch beyond the schedule, and <a href="https://cronradar.com/blog/quartz-net-job-not-firing">why a Quartz.NET job is not firing</a> covers a trigger that parses but never runs.</p>
<h2>Frequently Asked Questions</h2>
<h3>What cron format does Quartz.NET use?</h3>
<p>Six required fields, seconds first: second, minute, hour, day of month, month, day of week, with an optional seventh field for the year. Day of week runs 1 to 7 with Sunday as 1, and exactly one day field must be <code>?</code>.</p>
<h3>Why does Quartz say "Support for specifying both a day-of-week AND a day-of-month parameter is not implemented"?</h3>
<p>Neither day field is <code>?</code>, or both are. Put <code>?</code> in the field you aren't restricting: <code>0 0 2 * * ?</code> for every day, <code>0 0 9 ? * MON-FRI</code> for weekdays.</p>
<h3>How do I run a Quartz.NET job every 5 minutes?</h3>
<p>Use <code>0 0/5 * * * ?</code>. The seconds field is <code>0</code>, the minute step starts at :00, and day of week is <code>?</code>. The five-field equivalent is <code>*/5 * * * *</code>.</p>
<h3>What time zone does a Quartz.NET cron trigger use?</h3>
<p><code>TimeZoneInfo.Local</code> unless you call <code>InTimeZone</code> on the <code>CronScheduleBuilder</code>. Set it explicitly, <code>TimeZoneInfo.Utc</code> for jobs that should stay evenly spaced, so the trigger means the same thing on every host.</p>
<h3>Can a Quartz.NET expression with L, W, or # be monitored?</h3>
<p>Not as written in standard five-field cron. Schedule the job on every candidate date, such as <code>0 0 23 28-31 * ?</code> instead of <code>0 0 23 L * ?</code>, have the job exit early on the other days, and report the five-field form, <code>0 23 28-31 * *</code> for a trigger in UTC, with a check-in on every run, including the ones that exit early. CronRadar.Quartz 0.1.0 forwards the Quartz string unchanged, so send that schedule through the base CronRadar SDK or HTTP check-ins.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>quartz.net cron expression</category>
      <category>quartz cron L W #</category>
      <category>quartz CronScheduleBuilder InTimeZone</category>
      <category>quartz day-of-week AND day-of-month</category>
      <category>quartz cron to standard cron</category>
    </item>
    <item>
      <title><![CDATA[Run Claude Code and Codex on a Schedule: Auth, Limits, Results]]></title>
      <link>https://cronradar.com/blog/scheduled-coding-agent-runs</link>
      <guid>https://cronradar.com/blog/scheduled-coding-agent-runs</guid>
      <description><![CDATA[Run claude -p and codex exec from cron, systemd, or GitHub Actions: headless auth, permissions, turn and spend caps, exit codes, and catching no-op runs.]]></description>
      <content:encoded><![CDATA[<p><strong>To run Claude Code or Codex on a schedule, call its non-interactive mode, <code>claude -p</code> or <code>codex exec</code>, from a wrapper that supplies an API key from a secret store, grants only the permissions the task needs, and bounds the run's turns, spend, and wall-clock time.</strong> Then judge the run by its output, not its exit code. Both CLIs exit non-zero when a run fails outright, but both exit 0 when the agent finished without doing the work: Claude Code carries on after a tool call is denied, and neither checks whether anything changed. Have the agent end with a structured verdict, compare it with the working tree, and report the run to a monitor with the numbers attached.</p>
<p>This guide covers Claude Code 2.1.314, the current release on <a href="https://www.npmjs.com/package/@anthropic-ai/claude-code/v/2.1.314">npm</a> (checked October 30, 2026), and Codex CLI 0.166.0, the current release on <a href="https://www.npmjs.com/package/@openai/codex/v/0.166.0">npm</a> (checked October 30, 2026), plus <code>anthropics/claude-code-action</code> <a href="https://github.com/anthropics/claude-code-action/releases/tag/v1.0.265">v1.0.265</a>, which installs Claude Code 2.1.314 (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/src/entrypoints/run.ts#L80">run.ts</a>). Jobs that call a model API directly, and the ways they fail with an HTTP 200, are in <a href="https://cronradar.com/blog/monitoring-scheduled-ai-agent-runs">monitoring scheduled AI agent runs</a>; having an agent add monitoring code to a repository is <a href="https://cronradar.com/blog/ai-agent-cron-monitoring-setup">AI agent cron monitoring setup</a>.</p>
<h2>The Two CLIs Side by Side</h2>
<table>
<thead>
<tr>
<th></th>
<th>Claude Code 2.1.314</th>
<th>Codex CLI 0.166.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>Run without a session</td>
<td><code>claude -p "&#x3C;prompt>"</code></td>
<td><code>codex exec "&#x3C;prompt>"</code></td>
</tr>
<tr>
<td>Key for unattended runs</td>
<td><code>ANTHROPIC_API_KEY</code></td>
<td><code>CODEX_API_KEY</code></td>
</tr>
<tr>
<td>No permission flags given</td>
<td>Manual mode: calls that need approval are denied</td>
<td>Read-only sandbox, approval policy <code>never</code></td>
</tr>
<tr>
<td>Let it edit files</td>
<td><code>--allowedTools "Edit"</code> or <code>--permission-mode acceptEdits</code></td>
<td><code>--sandbox workspace-write</code></td>
</tr>
<tr>
<td>Cap turns and spend</td>
<td><code>--max-turns</code>, <code>--max-budget-usd</code></td>
<td>No flag for either</td>
</tr>
<tr>
<td>Machine-readable output</td>
<td><code>--output-format json</code></td>
<td><code>--json</code> (JSON Lines events)</td>
</tr>
<tr>
<td>Final answer in a schema</td>
<td><code>--json-schema '&#x3C;schema>'</code></td>
<td><code>--output-schema &#x3C;file></code></td>
</tr>
<tr>
<td>Failed run</td>
<td>Non-zero exit</td>
<td>Exit 1</td>
</tr>
</tbody>
</table>
<p>Sources: Claude Code's <a href="https://code.claude.com/docs/en/headless">headless mode</a> and <a href="https://code.claude.com/docs/en/cli-reference">CLI reference</a>; Codex's <a href="https://learn.chatgpt.com/docs/non-interactive-mode">non-interactive mode</a> guide and <a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs">exec CLI definition</a>. Each row is covered below.</p>
<h2>Claude Code: <code>claude -p</code></h2>
<h3>Authenticate Without a Browser</h3>
<p>Use an API key from the Claude Console in <code>ANTHROPIC_API_KEY</code>. In non-interactive mode, Claude Code always uses the key when it's present, and the key outranks <code>CLAUDE_CODE_OAUTH_TOKEN</code> and a <code>/login</code> subscription; cloud provider credentials and an <code>ANTHROPIC_AUTH_TOKEN</code> for an LLM gateway rank above it (<a href="https://code.claude.com/docs/en/authentication#authentication-precedence">authentication precedence</a>).</p>
<p>A subscription works too. <code>claude setup-token</code> runs the browser sign-in once and prints a one-year OAuth token for <code>CLAUDE_CODE_OAUTH_TOKEN</code>, on Pro, Max, Ultra, Team, or Enterprise plans (<a href="https://code.claude.com/docs/en/authentication#generate-a-long-lived-token">long-lived token</a>). It's a weaker fit for scheduled work: <code>--bare</code>, the recommended mode for scripts, doesn't read it; it's tied to the subscription of whoever generated it, which is why Anthropic recommends an API key for any secret shared across repositories (<a href="https://code.claude.com/docs/en/github-actions#set-up-for-an-organization">set up for an organization</a>); and advertised Pro, Max, and Ultra usage limits "assume ordinary, individual usage of Claude Code and the Agent SDK" (<a href="https://code.claude.com/docs/en/legal-and-compliance#acceptable-use">legal and compliance</a>). If you use one anyway, put its renewal date on a calendar.</p>
<h3>Start From a Known Configuration With <code>--bare</code></h3>
<p>Without <code>--bare</code>, <code>claude -p</code> loads what an interactive session would: hooks, skills, plugins, MCP servers, auto memory, and <code>CLAUDE.md</code>, from the working directory and <code>~/.claude</code>. It runs the hooks in the project's <code>.claude/settings.json</code> and connects the servers in its <code>.mcp.json</code> "even in a folder you've never trusted", with no trust dialog (<a href="https://code.claude.com/docs/en/headless#start-faster-with-bare-mode">bare mode</a>). On a schedule, that means a commit to the repository's Claude configuration changes what next week's run does.</p>
<p><code>--bare</code> skips all of that. Claude gets the Bash, file read, and file edit tools, and anything else is passed explicitly: <code>--append-system-prompt-file</code> for instructions such as your <code>CLAUDE.md</code>, <code>--settings</code> for settings, <code>--mcp-config</code> for MCP servers. It reads no OAuth credentials or keychain entries, so it authenticates with <code>ANTHROPIC_API_KEY</code> or an <code>apiKeyHelper</code>. It also turns background tasks off. A normal <code>-p</code> run can stay open after Claude's last turn waiting for background work it started, up to 10 minutes of idle waiting by default (<code>CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS</code>), or until a background command it started exits or reaches its time limit (<a href="https://code.claude.com/docs/en/headless#background-tasks-at-exit">background tasks at exit</a>). Anthropic calls <code>--bare</code> "the recommended mode for scripted and SDK calls" and says it will become the default for <code>-p</code>.</p>
<p>Two more settings keep runs repeatable:</p>
<ul>
<li><strong>Pin the CLI.</strong> Native installs update themselves in the background. Install a fixed version with <code>curl -fsSL https://claude.ai/install.sh | bash -s 2.1.314</code> and set <code>DISABLE_AUTOUPDATER=1</code> in the job's environment (<a href="https://code.claude.com/docs/en/setup#install-a-specific-version">install a specific version</a>, <a href="https://code.claude.com/docs/en/setup#disable-auto-updates">disable auto-updates</a>).</li>
<li><strong>Pin the model.</strong> Aliases such as <code>sonnet</code> "point to the recommended version for your provider and update over time" (<a href="https://code.claude.com/docs/en/model-config#model-aliases">model aliases</a>). Pass a full model name instead, as in the CLI reference's <code>--model claude-sonnet-5</code>.</li>
</ul>
<h3>Permissions for a Run Nobody Watches</h3>
<p>Since 2.1.302, a <code>claude -p</code> run with no permission mode set starts in Manual mode (<code>default</code>). File reads in the working directories and a built-in set of read-only commands run; any call that needs approval is denied unless an allow rule, a hook, or <code>--permission-prompt-tool</code> approves it (<a href="https://code.claude.com/docs/en/permission-modes#which-mode-a-non-interactive-run-starts-in">non-interactive runs</a>). Choose the mode on purpose:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Flags</th>
<th>What runs without a person</th>
<th>Use it for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>dontAsk</code> with an allowlist</td>
<td><code>--permission-mode dontAsk --allowedTools "Read,Edit,Bash(npm test *)"</code></td>
<td>Reads in the working directories, read-only commands, and what the list names</td>
<td>Most scheduled jobs</td>
</tr>
<tr>
<td><code>auto</code></td>
<td><code>--permission-mode auto --permission-prompts none</code></td>
<td>Reads and edits in the working directory, plus actions a classifier model approves</td>
<td>Open-ended tasks you can't enumerate</td>
</tr>
<tr>
<td><code>bypassPermissions</code></td>
<td><code>--dangerously-skip-permissions</code></td>
<td>Everything</td>
<td>Only in a container or VM, as a non-root user</td>
</tr>
</tbody>
</table>
<p>Sources: <a href="https://code.claude.com/docs/en/permission-modes#common-setups">common setups</a>, <a href="https://code.claude.com/docs/en/permission-modes#allow-only-pre-approved-tools-with-dontask-mode">dontAsk</a>, <a href="https://code.claude.com/docs/en/permission-modes#how-the-classifier-evaluates-actions">how the classifier evaluates actions</a>, <a href="https://code.claude.com/docs/en/headless#turn-off-permission-prompts-in-unattended-runs">unattended runs</a>.</p>
<ul>
<li><strong>Allowlist syntax.</strong> <code>Bash(npm test *)</code> matches <code>npm test</code> alone and any command that starts with <code>npm test </code>; the space before <code>*</code> is part of the rule (<a href="https://code.claude.com/docs/en/permissions#wildcard-patterns">wildcard patterns</a>). A compound command such as <code>npm test &#x26;&#x26; curl ...</code> runs only if every part is allowed (<a href="https://code.claude.com/docs/en/permissions#compound-commands">compound commands</a>).</li>
<li><strong>Auto mode can fall back.</strong> When <code>--permission-mode auto</code> asks for auto mode but it isn't available, because a settings file disables it, the model doesn't support it, or Anthropic has turned it off server-side, the session starts in Manual mode instead (<a href="https://code.claude.com/docs/en/permission-modes#which-mode-a-session-starts-in">which mode a session starts in</a>). And after the classifier blocks an action 3 times in a row or 20 times in a session, a <code>-p</code> run doesn't stop: "the action doesn't run and Claude keeps working" (<a href="https://code.claude.com/docs/en/permission-modes#when-auto-mode-falls-back">fallback</a>). <code>--permission-prompts none</code> (2.1.259 and later) also tells Claude not to retry denied requests.</li>
<li><strong><code>bypassPermissions</code> has no brakes.</strong> It "offers no protection against prompt injection or unintended actions", and Claude Code refuses it when running as root or under <code>sudo</code> on Linux and macOS (<a href="https://code.claude.com/docs/en/permission-modes#skip-all-checks-with-bypasspermissions-mode">bypassPermissions</a>).</li>
</ul>
<p>In every mode, a denied call doesn't end the run, so a run can finish as <code>success</code> after some of its calls were denied. That's why the wrapper below counts denials.</p>
<h3>Limit What the Agent Can Reach</h3>
<p>Two settings narrow what Claude's shell commands can see:</p>
<ul>
<li><strong><code>CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1</code></strong> removes credentials such as <code>ANTHROPIC_API_KEY</code> from the environments of the Bash commands, hooks, and stdio MCP servers Claude Code starts, while Claude Code keeps them for its own API calls. The docs describe it as reducing "what a prompt injection attack can read". It recognizes credentials by variable name or value shape and leaves <code>GITHUB_TOKEN</code> and <code>GH_TOKEN</code> in place (<a href="https://code.claude.com/docs/en/env-vars#what-the-subprocess-environment-scrub-removes">what the scrub removes</a>).</li>
<li><strong><code>permissions.blockReadsOutsideWorkingDirectories</code>.</strong> <code>cat</code>, <code>head</code>, <code>grep</code>, and the rest of the read-only command set run without approval in every mode, including on paths outside the repository (<a href="https://code.claude.com/docs/en/permissions#read-only-commands">read-only commands</a>). Setting this to <code>true</code> (2.1.257 and later) makes Claude's file tools refuse reads outside the working directories (<a href="https://code.claude.com/docs/en/settings-reference#permissions-blockreadsoutsideworkingdirectories">setting</a>) and makes recognized file-reading shell commands on those paths prompt in every mode (<a href="https://code.claude.com/docs/en/permission-modes#actions-no-mode-auto-approves">actions no mode auto-approves</a>), which in a <code>-p</code> run with no one to answer means a denial.</li>
</ul>
<p>Neither setting reaches a program that opens files itself. Claude Code's file rules cover its own tools and the file commands it recognizes in Bash, not "arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself" (<a href="https://code.claude.com/docs/en/permissions#what-read-and-edit-rules-cover">what Read and Edit rules cover</a>), and an allowed <code>npm test</code> runs the <code>test</code> script from <code>package.json</code> (<a href="https://docs.npmjs.com/cli/v11/commands/npm-test">npm test</a>), a file the agent can edit.</p>
<p>So keep the job's secrets file outside the checkout and hand each key only to the command that uses it, as the wrapper below does, but treat that as keeping keys out of the agent's environment, not out of its reach. The agent runs as the job's account, and a command it starts can read whatever that account can, including the secrets file, and use that account's Git credentials. To close that gap, run the agent CLI as a second account that can read neither.</p>
<h3>Turn, Spend, and Time Limits</h3>
<ul>
<li><strong><code>--max-turns N</code></strong> limits agentic turns in print mode. There's no limit by default, and the run "exits with an error when the limit is reached" (<a href="https://code.claude.com/docs/en/cli-reference#cli-flags">CLI flags</a>), with the result subtype <code>error_max_turns</code>.</li>
<li><strong><code>--max-budget-usd N</code></strong> stops the run once estimated spend reaches N, with the subtype <code>error_max_budget_usd</code>. The estimate is computed client-side and can differ from your bill, subagent spend counts toward it, and the response that crosses the cap still completes, so spend can pass the cap by up to that response's cost, plus whatever subagents still running at that moment spend before they stop (<a href="https://code.claude.com/docs/en/agent-sdk/agent-loop#budget-headroom">budget headroom</a>).</li>
<li><strong>No wall-clock flag.</strong> Wrap the command in <code>timeout</code>. On SIGTERM, which <code>timeout</code> sends by default, Claude Code exits with code 143 and records no result for the turn in progress (<a href="https://code.claude.com/docs/en/headless#stop-a-run-with-sigterm">SIGTERM</a>); <code>timeout</code> itself exits 124 when the command times out, or 137 when the command or <code>timeout</code> receives KILL, which is what <code>--kill-after</code> sends to a command still running after TERM (<a href="https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html">GNU coreutils</a>). <a href="https://cronradar.com/blog/cron-job-timeout">Cron job timeouts</a> covers choosing the limit.</li>
</ul>
<h3>Output and Exit Codes</h3>
<p>Claude Code "exits with code 0 on success and a non-zero code when the run fails". An invalid flag is reported on stderr before the run starts; a failure inside the run, "such as missing authentication", is printed as the result on stdout (<a href="https://code.claude.com/docs/en/headless#basic-usage">basic usage</a>).</p>
<p><code>--output-format json</code> prints the run's result as a JSON object with session metadata. These are the fields a scheduled job reads (<a href="https://code.claude.com/docs/en/agent-sdk/agent-loop#handle-the-result">handle the result</a>, <a href="https://code.claude.com/docs/en/agent-sdk/typescript#sdkresultmessage"><code>SDKResultMessage</code></a>):</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>subtype</code></td>
<td><code>success</code>, <code>error_max_turns</code>, <code>error_max_budget_usd</code>, <code>error_during_execution</code>, or <code>error_max_structured_output_retries</code></td>
</tr>
<tr>
<td><code>is_error</code></td>
<td><code>true</code> when the run errored; claude-code-action treats <code>subtype: "success"</code> with <code>is_error: true</code> as a failure</td>
</tr>
<tr>
<td><code>num_turns</code>, <code>total_cost_usd</code></td>
<td>Turns taken and estimated cost in USD, on every subtype</td>
</tr>
<tr>
<td><code>permission_denials</code></td>
<td>One entry per denied tool call, with the tool's name and input</td>
</tr>
<tr>
<td><code>result</code></td>
<td>The final text, on <code>success</code> only</td>
</tr>
<tr>
<td><code>structured_output</code></td>
<td>The final answer as JSON matching <code>--json-schema</code>, when the run produced one</td>
</tr>
</tbody>
</table>
<p>claude-code-action's rule, from <a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/run-claude-sdk.ts#L252-L256">run-claude-sdk.ts</a>, is the one to copy: a run succeeded only if <code>subtype</code> is <code>success</code> and <code>is_error</code> is <code>false</code>, because, as its source comment puts it, a <code>success</code> subtype with <code>is_error:true</code> "means the run errored without producing a real result". For <code>--json-schema</code>, "if a run ends without valid structured output, for example because Claude declined the request, the response has no <code>structured_output</code> field", and a schema that isn't valid JSON Schema makes <code>claude</code> exit with <code>Error: --json-schema is not a valid JSON Schema</code> (<a href="https://code.claude.com/docs/en/headless#get-structured-output">structured output</a>).</p>
<h2>Codex CLI: <code>codex exec</code></h2>
<h3>Authenticate With <code>CODEX_API_KEY</code></h3>
<p><code>codex exec</code> reuses the CLI's saved login by default. For automation, OpenAI's guidance is to set <code>CODEX_API_KEY</code> "only for the Codex invocation that needs it", to make sure no untrusted code runs in the same process environment, and never to set <code>OPENAI_API_KEY</code> or <code>CODEX_API_KEY</code> as a job-level variable in a workflow that checks out or runs repository code (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation">authenticate in automation</a>):</p>
<pre><code class="language-bash">CODEX_API_KEY="$CODEX_API_KEY" codex exec --json "triage open bug reports"
</code></pre>
<p>To store a key instead, pipe it in with <code>printenv OPENAI_API_KEY | codex login --with-api-key</code> (<a href="https://learn.chatgpt.com/docs/auth">authentication</a>); <code>codex login status</code> exits 0 when credentials are present, which makes a cheap preflight check (<a href="https://learn.chatgpt.com/docs/developer-commands">developer commands</a>). With an API key, Codex uses standard API pricing instead of ChatGPT plan credits (<a href="https://learn.chatgpt.com/docs/auth">authentication</a>). Running CI as a ChatGPT account is documented as an advanced path: <code>~/.codex/auth.json</code> holds access tokens, has to be refreshed in place and persisted between runs, and isn't meant for public or open-source repositories (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation">authenticate in automation</a>).</p>
<p>Pin the CLI with <code>npm install -g @openai/codex@0.166.0</code>; npm is one of the documented install methods (<a href="https://learn.chatgpt.com/docs/codex/cli">Codex CLI</a>).</p>
<h3>Sandbox and Approvals</h3>
<ul>
<li><strong>Read-only by default.</strong> <code>codex exec</code> runs in a read-only sandbox unless you pass <code>--sandbox workspace-write</code>, which lets it edit within the workspace. Use <code>danger-full-access</code> "only in a controlled environment", such as an isolated CI runner or container (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#permissions-and-safety">permissions and safety</a>).</li>
<li><strong>No network, no commits.</strong> In <code>workspace-write</code>, network access stays off unless <code>sandbox_workspace_write.network_access</code> is set (<a href="https://learn.chatgpt.com/docs/agent-approvals-security#network-access">network access</a>), and <code>.git</code> is protected as read-only (<a href="https://learn.chatgpt.com/docs/agent-approvals-security#protected-paths-in-writable-roots">protected paths</a>). Install dependencies before Codex starts, and commit from the wrapper.</li>
<li><strong>Nothing waits for approval.</strong> <code>exec</code> sets the approval policy to <code>never</code> unless your configuration selects automatic review (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L595-L597">lib.rs</a>), and it rejects any approval request that still arrives, with errors such as "command execution approval is not supported in exec mode" (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L2166-L2177">lib.rs</a>).</li>
<li><strong>Old and dangerous flags.</strong> <code>--full-auto</code> is no longer supported (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#permissions-and-safety">permissions and safety</a>). <code>--approve-for-me</code> routes approval requests through automatic review with the workspace-write sandbox, and <code>--dangerously-bypass-approvals-and-sandbox</code> (alias <code>--yolo</code>) is "EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed" (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/utils/cli/src/shared_options.rs#L43-L59">shared_options.rs</a>).</li>
<li><strong>Ignore the host's configuration.</strong> <code>--ignore-user-config</code> skips <code>$CODEX_HOME/config.toml</code> (auth still uses <code>CODEX_HOME</code>), <code>--ignore-rules</code> skips user and project execpolicy <code>.rules</code> files, and <code>--ephemeral</code> writes no session files (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs#L36-L50">cli.rs</a>). With them, a run uses the flags you pass rather than whatever the host's config file says.</li>
<li><strong>Git repository required.</strong> Outside one, <code>exec</code> prints "Not inside a trusted directory and --skip-git-repo-check was not specified." and exits 1 (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L1017-L1025">lib.rs</a>). An enabled MCP server marked <code>required = true</code> that fails to initialize also makes <code>codex exec</code> exit with an error instead of continuing without it (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#permissions-and-safety">permissions and safety</a>).</li>
</ul>
<h3>Limits</h3>
<p><code>codex exec</code> 0.166.0 has no turn cap and no spend cap: neither its own options nor the shared ones define such a flag (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/cli.rs">cli.rs</a>, <a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/utils/cli/src/shared_options.rs">shared_options.rs</a>). Bound it with <code>timeout</code>, and watch spend through the token <code>usage</code> in its <code>turn.completed</code> event.</p>
<h3>Output and Exit Codes</h3>
<p>Codex streams progress to stderr and prints only the final agent message to stdout. With <code>--json</code>, stdout becomes JSON Lines, one event per line (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#make-output-machine-readable">machine-readable output</a>). In 0.166.0 the events are <code>thread.started</code>, <code>turn.started</code>, <code>turn.completed</code> with token <code>usage</code>, <code>turn.failed</code> with an <code>error</code>, <code>item.started</code>, <code>item.updated</code>, <code>item.completed</code>, and <code>error</code> (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/exec_events.rs#L11-L57">exec_events.rs</a>). Items include agent messages, file changes, and command executions with their <code>exit_code</code> and a <code>status</code> such as <code>failed</code> or <code>declined</code> (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/exec_events.rs">exec_events.rs</a>). <code>--output-schema &#x3C;file></code> requests a final response that conforms to a JSON Schema, and <code>-o</code>/<code>--output-last-message &#x3C;file></code> writes the final message to a file.</p>
<p><code>exec</code> exits 1 when the turn fails or is interrupted, or when Codex reports an error it won't retry; a turn that completes exits 0 (<a href="https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/exec/src/lib.rs#L1307-L1416">lib.rs</a>), whatever the agent did or didn't change.</p>
<h2>Exit 0 Doesn't Mean the Work Happened</h2>
<table>
<thead>
<tr>
<th>What happened</th>
<th>Claude Code shows</th>
<th>Codex CLI shows</th>
<th>Exit code</th>
</tr>
</thead>
<tbody>
<tr>
<td>Hit the turn or spend cap</td>
<td><code>subtype</code> <code>error_max_turns</code> or <code>error_max_budget_usd</code></td>
<td>No such caps</td>
<td>Non-zero</td>
</tr>
<tr>
<td>Authentication or API failure</td>
<td>The failure printed as the result</td>
<td><code>turn.failed</code> with the error message</td>
<td>Non-zero (Codex: 1)</td>
</tr>
<tr>
<td>Stopped by your time limit</td>
<td>No result for the interrupted turn</td>
<td>No <code>turn.completed</code></td>
<td>124 from <code>timeout</code> (137 after KILL)</td>
</tr>
<tr>
<td>Tool calls denied</td>
<td>Entries in <code>permission_denials</code>; the run keeps going</td>
<td>Rejected approval requests; <code>command_execution</code> items with a <code>failed</code> or <code>declined</code> status</td>
<td>Often 0</td>
</tr>
<tr>
<td>No valid verdict, such as after a refusal</td>
<td>No <code>structured_output</code>, or <code>error_max_structured_output_retries</code></td>
<td><code>-o</code> file missing or not the expected JSON</td>
<td>0 or non-zero</td>
</tr>
<tr>
<td>Agent says it finished, but nothing changed</td>
<td>Verdict and <code>git status</code> disagree</td>
<td>Same</td>
<td>0</td>
</tr>
<tr>
<td>Genuinely nothing to do</td>
<td>Verdict <code>nothing_to_do</code>, clean tree</td>
<td>Same</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>The exit code separates the first three rows from the rest. For the others, ask the agent for a verdict in a fixed shape, then check it against what's on disk. One schema works for both CLIs, written the way Codex's own example writes them, with every field required and <code>additionalProperties</code> false:</p>
<pre><code class="language-json">{
  "type": "object",
  "properties": {
    "status": { "type": "string", "enum": ["done", "nothing_to_do", "blocked"] },
    "summary": { "type": "string" }
  },
  "required": ["status", "summary"],
  "additionalProperties": false
}
</code></pre>
<p>Claude Code takes it inline, as <code>--json-schema "$(cat result-schema.json)"</code>; Codex takes the path. The prompt says what each status means:</p>
<pre><code class="language-markdown">Fix the ESLint warnings that `npm run lint` reports under src/. Don't change
behavior, public APIs, tests, or anything outside src/. Run `npm run lint` and
`npm test` before you finish.

End with status "done" if you changed files and both commands pass,
"nothing_to_do" if there were no warnings, or "blocked" if you couldn't finish,
plus a short summary.
</code></pre>
<p>Then the wrapper believes the verdict only when the working tree agrees. <code>git status --porcelain --untracked-files=all</code> prints one line per modified, staged, or untracked file (<a href="https://git-scm.com/docs/git-status">git-status</a>), so <code>done</code> with zero lines, or <code>nothing_to_do</code> with some, is a failure. That catches the run <a href="https://cronradar.com/blog/scheduled-job-silent-success">scheduled job silent success</a> warns about, one where every command exited 0 and nothing useful happened.</p>
<h2>A Wrapper for cron and systemd Timers</h2>
<p>There's no CronRadar SDK for shell scripts, and HTTP check-ins don't discover jobs automatically, so the wrapper reports each run itself: <code>start</code>, then <code>complete</code> with outcome values, or <code>fail</code> with a short message. Check-ins use HTTP Basic auth with the key as the username and an empty password, so the key stays out of URLs and the crontab.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# /opt/agent-jobs/lint-fix.sh: run a coding agent unattended, check what it did,
# push its branch for review, and report the run to CronRadar.
set -u
umask 077
PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin

KEY=weekly-lint-fix
SCHEDULE='30 4 * * 1' # Mondays 04:30 UTC, the same time as the crontab line
GRACE=900
JOBS=/opt/agent-jobs
REPO=/srv/agent-jobs/webapp # a clone used only by this job
URL="https://cron.life/api/v1/ping/$KEY"
RUN_ID="$KEY-$(date +%s)-$$"

# ANTHROPIC_API_KEY=... and CRONRADAR_API_KEY=... lines; mode 0600, owned by the
# job's account. Sourced without exporting, so each key is in the environment of
# only the command it's passed to below.
. /etc/agent-jobs/secrets.env || exit 1

checkin() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=$SCHEDULE" --data-urlencode "gracePeriod=$GRACE" \
    --data-urlencode "run_id=$RUN_ID" "$@" "$URL/$event" || true
}

fail() {
  echo "$KEY: $1" >&#x26;2
  checkin fail --data-urlencode "message=$1"
  exit 1
}

run_agent() { # sets rc, status, and extra (this tool's outcome values)
  timeout --kill-after=60 45m env \
    ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
    CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 \
    DISABLE_AUTOUPDATER=1 \
    claude --bare -p "$(cat "$JOBS/lint-fix.md")" \
    --model claude-sonnet-5 \
    --output-format json \
    --json-schema "$(cat "$JOBS/result-schema.json")" \
    --permission-mode dontAsk \
    --allowedTools "Read,Edit,Bash(npm run lint *),Bash(npm test *)" \
    --settings '{"permissions":{"blockReadsOutsideWorkingDirectories":true}}' \
    --max-turns 40 \
    --max-budget-usd 5 \
    > "$work/result.json" 2> "$work/stderr.log"
  rc=$?
  read -r subtype is_error status denials turns cost &#x3C;&#x3C;&#x3C; "$(jq -r '[.subtype, .is_error,
    (.structured_output.status // "none"), (.permission_denials | length), .num_turns,
    ((.total_cost_usd // 0) * 100 | floor)] | map(tostring) | join(" ")' \
    "$work/result.json" 2> /dev/null)"
  # Success means subtype "success" and is_error false, as claude-code-action checks.
  if [ "$rc" -eq 0 ] &#x26;&#x26; { [ "$subtype" != success ] || [ "$is_error" != false ]; }; then
    rc=1
  fi
  extra="\"permission_denials\":${denials:-0},\"turns\":${turns:-0},\"cost_cents\":${cost:-0}"
}

checkin start
work="/var/log/agent-jobs/$RUN_ID" # this run's output, kept for debugging
mkdir -p "$work" || fail "cannot create $work"

cd "$REPO" || fail "cannot enter $REPO"
{ git fetch --quiet origin &#x26;&#x26; git checkout --quiet --force --detach origin/main &#x26;&#x26;
  git clean -fdq; } || fail "could not reset the checkout to origin/main"
npm ci --silent || fail "npm ci failed" # dependencies come before the agent starts

run_agent
case $rc in
  0) ;;
  124) fail "agent stopped at the 45-minute limit" ;;
  137) fail "agent killed with KILL, by the 45-minute limit or another process" ;;
  *) fail "agent run failed with exit code $rc" ;;
esac
case $status in
  done | nothing_to_do | blocked) ;;
  *) fail "agent returned no valid verdict" ;;
esac

files_changed=$(($(git status --porcelain --untracked-files=all | wc -l)))
case "$status:$((files_changed > 0))" in
  done:0) fail "agent reported done but changed no files" ;;
  nothing_to_do:1) fail "agent reported nothing to do but changed files" ;;
esac

if [ "$status" = done ]; then
  branch="agent/$KEY-$(date -u +%Y%m%d)"
  { git switch --quiet --create "$branch" &#x26;&#x26; git add --all; } ||
    fail "could not stage the changes on $branch"
  # Refuse to push changes that contain one of this job's keys as plain text.
  for secret in "${ANTHROPIC_API_KEY:-}" "${CODEX_API_KEY:-}" "$CRONRADAR_API_KEY"; do
    if [ -n "$secret" ] &#x26;&#x26; git diff --cached --text | grep -qF -- "$secret"; then
      fail "the agent's changes contain an API key; nothing was pushed"
    fi
  done
  { git commit --quiet --message "Fix lint warnings (scheduled agent run)" &#x26;&#x26;
    git push --quiet origin "$branch"; } || fail "could not push $branch"
fi

blocked=false
[ "$status" != blocked ] || blocked=true
curl --fail --silent --show-error --max-time 5 --output /dev/null \
  --request POST --user "$CRONRADAR_API_KEY:" --header "Content-Type: application/json" \
  --data "{\"outcomes\":{\"files_changed\":$files_changed,\"blocked\":$blocked,$extra}}" \
  "$URL/complete?run_id=$RUN_ID" || true
</code></pre>
<p>For Codex, keep everything else, put <code>CODEX_API_KEY=...</code> in the secrets file, and replace <code>run_agent</code>:</p>
<pre><code class="language-bash">run_agent() { # Codex CLI 0.166.0: same contract as the Claude Code version
  timeout --kill-after=60 45m env CODEX_API_KEY="$CODEX_API_KEY" \
    codex exec --json --ephemeral --ignore-user-config --ignore-rules \
    --sandbox workspace-write \
    --output-schema "$JOBS/result-schema.json" \
    --output-last-message "$work/final.json" \
    "$(cat "$JOBS/lint-fix.md")" \
    > "$work/events.jsonl" 2> "$work/stderr.log"
  rc=$?
  status=$(jq -r '.status' "$work/final.json" 2> /dev/null) || status=none
  tokens=$(jq -s '[.[] | select(.type == "turn.completed")] | last | .usage.output_tokens // 0' \
    "$work/events.jsonl" 2> /dev/null) || tokens=0
  extra="\"output_tokens\":$tokens"
}
</code></pre>
<p>Schedule it from the job account's crontab, on a host whose clock is UTC (<a href="https://cronradar.com/blog/cron-timezone">cron time zones</a> covers the alternatives):</p>
<pre><code class="language-text">30 4 * * 1 /opt/agent-jobs/lint-fix.sh >> /var/log/agent-jobs/lint-fix.log 2>&#x26;1
</code></pre>
<p>Or with a systemd timer. <code>OnCalendar=</code> accepts the literal zone <code>UTC</code> (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html">systemd.time(7)</a>), and because a <code>Type=oneshot</code> service has no start timeout by default (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html">systemd.service(5)</a>), <code>TimeoutStartSec=</code> is a backstop above the wrapper's own limit. <a href="https://cronradar.com/blog/systemd-timers">systemd timers</a> covers the rest of the options:</p>
<pre><code class="language-ini"># /etc/systemd/system/weekly-lint-fix.service
[Service]
Type=oneshot
User=agentjobs
ExecStart=/opt/agent-jobs/lint-fix.sh
TimeoutStartSec=1h

# /etc/systemd/system/weekly-lint-fix.timer
[Timer]
OnCalendar=Mon *-*-* 04:30:00 UTC

[Install]
WantedBy=timers.target
</code></pre>
<p>What the wrapper does, and why:</p>
<ul>
<li><strong><code>start</code> comes first</strong>, so a broken checkout or a failed <code>npm ci</code> reports <code>fail</code> instead of looking like a missed run. A missing secrets file exits before any check-in, and CronRadar alerts on the run that never arrived: <code>warning</code> once it's late, <code>critical</code> once the grace period passes.</li>
<li><strong>The check-ins work as in <a href="https://cronradar.com/blog/github-actions-scheduled-workflows">GitHub Actions scheduled workflows</a>:</strong> they can't break the job, the <code>start</code> carries the schedule so the first run creates the monitor, and a run that dies after <code>start</code> is flagged as stuck only once the next scheduled time plus grace passes, which for a weekly job is next Monday. CronRadar evaluates the schedule as five-field cron in UTC. The <code>timeout</code> turns a hung agent into a <code>fail</code> at 45 minutes, which opens an incident immediately.</li>
<li><strong>Keys stay out of the agent's environment, not out of its reach.</strong> <code>ANTHROPIC_API_KEY</code> is passed only to <code>claude</code>, and <code>CRONRADAR_API_KEY</code> only to the check-in requests, but the agent runs as the job's account, which can read the secrets file; the second account described <a href="#limit-what-the-agent-can-reach">above</a> closes that.</li>
<li><strong>The verdict has to match the tree.</strong> A run that claims work it didn't do, or does work it didn't report, fails; a <code>blocked</code> run completes with <code>blocked: true</code>, which the outcome checks below turn into an incident.</li>
<li><strong>Only the wrapper pushes.</strong> The Claude Code allowlist names no <code>git</code> command, and Codex's <code>workspace-write</code> sandbox keeps <code>.git</code> read-only and the network off. The wrapper pushes only a <code>done</code> run, to a new branch, for a person to review, and refuses when the staged changes contain one of the job's keys as plain text. That check misses an encoded key, and the push credentials belong to the account the agent runs as, so the second account is the real boundary. For schedules frequent enough to overlap, add a lock as in <a href="https://cronradar.com/blog/prevent-overlapping-cron-jobs">preventing overlapping cron jobs</a>.</li>
</ul>
<h2>A Scheduled GitHub Actions Workflow</h2>
<p>With a <code>prompt</code> input, <code>anthropics/claude-code-action</code> runs in automation mode on any event, including <code>schedule</code>. Claude has no shell or GitHub API access until you grant tools, and results appear in the workflow run log (<a href="https://code.claude.com/docs/en/github-actions#interactive-and-automation-modes">automation mode</a>, <a href="https://code.claude.com/docs/en/github-actions#run-on-a-schedule">run on a schedule</a>). Three details matter for scheduled runs:</p>
<ul>
<li><strong>The bot check applies.</strong> The action rejects a bot actor unless it's listed in <code>allowed_bots</code>, and GitHub attributes a scheduled run to a repository user, "usually the one who last changed the workflow's <code>cron</code> schedule". If a bot made that change, scheduled runs fail (<a href="https://code.claude.com/docs/en/github-actions#who-can-trigger-runs">who can trigger runs</a>).</li>
<li><strong>Claude inherits the step's environment.</strong> The action builds Claude's environment from a copy of the whole process environment, minus the OIDC token request variables and the serialized action inputs (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/parse-sdk-options.ts#L279-L294">parse-sdk-options.ts</a>). Give <code>CRONRADAR_API_KEY</code> to the report steps only, never at job level.</li>
<li><strong>The outputs say what happened.</strong> <code>execution_file</code> is the path of a JSON array of every message from the run, including the final result (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/action.yml#L168-L174">action.yml</a>, <a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/execution-file.ts">execution-file.ts</a>). <code>conclusion</code> is <code>success</code> when the run succeeds; on a failed run the action fails the step without setting it, although <code>action.yml</code> describes it as <code>success</code> or <code>failure</code> (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/src/entrypoints/run.ts#L294-L324">run.ts</a>), so test for <code>success</code>, as the report step below does. With <code>--json-schema</code> in <code>claude_args</code>, a run that returns no structured output fails the step, and by default the log shows only a sanitized summary of the result (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/base-action/src/run-claude-sdk.ts">run-claude-sdk.ts</a>); the <code>show_full_output</code> input warns that full output "may contain secrets" (<a href="https://github.com/anthropics/claude-code-action/blob/v1.0.265/action.yml#L156-L157">action.yml</a>).</li>
</ul>
<p>To avoid storing an Anthropic key at all, the action can exchange the workflow's GitHub OIDC token through workload identity federation, with the <code>anthropic_federation_rule_id</code> and <code>anthropic_organization_id</code> inputs and <code>id-token: write</code> (<a href="https://code.claude.com/docs/en/github-actions#set-up-for-an-organization">set up for an organization</a>). This workflow uses a repository secret:</p>
<pre><code class="language-yaml"># .github/workflows/daily-report.yml
name: Daily report

on:
  schedule:
    - cron: '17 9 * * *'
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}

jobs:
  report:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      issues: read
      id-token: write
    defaults:
      run:
        shell: bash
    env:
      PING_URL: https://cron.life/api/v1/ping/daily-repo-report
      MONITOR_SCHEDULE: '17 9 * * *' # keep in step with on.schedule
      GRACE_SECONDS: '3600'
    steps:
      - name: Report start to CronRadar
        timeout-minutes: 1
        env:
          CRONRADAR_API_KEY: ${{ secrets.CRONRADAR_API_KEY }}
        run: |
          curl --fail --silent --show-error --max-time 5 --output /dev/null \
            --request POST --get --user "$CRONRADAR_API_KEY:" \
            --data-urlencode "schedule=$MONITOR_SCHEDULE" \
            --data-urlencode "gracePeriod=$GRACE_SECONDS" \
            --data-urlencode "run_id=$GITHUB_RUN_ID" \
            "$PING_URL/start" || true

      - name: Run Claude Code
        id: claude
        uses: anthropics/claude-code-action@6d5ae526d25ce88daa2200be073e1a373167f064 # v1.0.265
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: |
            Summarize yesterday's commits and the issues opened yesterday.
            End with status "done" and the summary, "nothing_to_do" if there
            were no commits or new issues, or "blocked" if you couldn't read them.
          claude_args: |
            --model claude-sonnet-5
            --allowedTools "mcp__github__list_commits,mcp__github__list_issues"
            --max-turns 20
            --max-budget-usd 2
            --json-schema '{"type":"object","properties":{"status":{"type":"string","enum":["done","nothing_to_do","blocked"]},"summary":{"type":"string"}},"required":["status","summary"],"additionalProperties":false}'

      - name: Report result to CronRadar
        if: always()
        timeout-minutes: 1
        env:
          CRONRADAR_API_KEY: ${{ secrets.CRONRADAR_API_KEY }}
          CONCLUSION: ${{ steps.claude.outputs.conclusion }}
          EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
        run: |
          body=
          if [ "$CONCLUSION" = success ]; then
            body=$(jq -c '[.[] | select(.type == "result")] | last | {outcomes: {
              blocked: (.structured_output.status == "blocked"),
              permission_denials: (.permission_denials | length),
              turns: .num_turns,
              cost_cents: ((.total_cost_usd // 0) * 100 | floor)}}' "$EXECUTION_FILE") || body=
          fi
          if [ -n "$body" ]; then
            curl --fail --silent --show-error --max-time 5 --output /dev/null \
              --request POST --user "$CRONRADAR_API_KEY:" \
              --header "Content-Type: application/json" --data "$body" \
              "$PING_URL/complete?run_id=$GITHUB_RUN_ID" || true
          else
            curl --fail --silent --show-error --max-time 5 --output /dev/null \
              --request POST --get --user "$CRONRADAR_API_KEY:" \
              --data-urlencode "run_id=$GITHUB_RUN_ID" \
              --data-urlencode "message=Claude Code run did not succeed: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
              "$PING_URL/fail" || true
          fi
</code></pre>
<p>The allowed tools and the <code>id-token</code> permission follow Anthropic's own scheduled example, which reads commits and issues through two GitHub MCP tools, so it needs no checkout (<a href="https://code.claude.com/docs/en/github-actions#run-on-a-schedule">run on a schedule</a>). The action pin is the v1.0.265 commit, which also fixes the Claude Code version. <code>jq</code> is preinstalled on the <code>ubuntu-24.04</code> image (<a href="https://github.com/actions/runner-images/blob/ubuntu24/20261026.354/images/ubuntu/Ubuntu2404-Readme.md">runner image</a>). The report step runs after a failure, a cancellation, or the job's timeout, and anything but a successful run reports <code>fail</code>. GitHub's own schedule can start runs late or drop them; <a href="https://cronradar.com/blog/github-actions-scheduled-workflows">GitHub Actions scheduled workflows</a> covers that and why the grace here is an hour.</p>
<p>For Codex, OpenAI recommends <code>openai/codex-action@v1</code> over installing and authenticating the CLI yourself (<a href="https://learn.chatgpt.com/docs/non-interactive-mode#authenticate-in-automation">non-interactive mode</a>). It starts a Responses API proxy for the key, runs <code>codex exec</code>, removes <code>sudo</code> before running Codex by default (<code>safety-strategy: drop-sudo</code>), takes extra flags such as <code>--output-schema</code> through <code>codex-args</code>, and returns the last message in its <code>final-message</code> output (<a href="https://learn.chatgpt.com/docs/github-action">Codex GitHub Action</a>). The same start and result steps fit around it, with the verdict read from <code>final-message</code> instead of the execution file.</p>
<h2>Outcome Checks That Catch Quiet Runs</h2>
<p>Set expectations on the reported values under the monitor's Outcome checks, and a <code>complete</code> that misses one marks the run outcome-failed and opens an incident; <a href="https://cronradar.com/blog/scheduled-job-silent-success">scheduled job silent success</a> covers the rules for expectations and the <code>complete</code> body. For the two jobs above:</p>
<table>
<thead>
<tr>
<th>Outcome check</th>
<th>Job</th>
<th>Catches</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>blocked is false</code></td>
<td>Every job</td>
<td>The agent couldn't finish and said so</td>
</tr>
<tr>
<td><code>permission_denials = 0</code></td>
<td>Claude Code jobs</td>
<td>Calls the run's permissions didn't allow, including auto mode falling back or blocking</td>
</tr>
<tr>
<td><code>turns &#x3C;= 30</code></td>
<td>Weekly lint fix</td>
<td>A task taking far more steps than usual, before <code>--max-turns 40</code> stops it</td>
</tr>
<tr>
<td><code>cost_cents &#x3C;= 300</code></td>
<td>Weekly lint fix</td>
<td>Spend creeping toward the <code>--max-budget-usd 5</code> cap</td>
</tr>
<tr>
<td><code>files_changed &#x3C;= 40</code></td>
<td>Weekly lint fix</td>
<td>An agent that touched far more files than a lint fix should</td>
</tr>
</tbody>
</table>
<p>For the daily report, use <code>turns &#x3C;= 15</code> and <code>cost_cents &#x3C;= 100</code> under its caps of 20 turns and $2; for the Codex variant, put a ceiling on <code>output_tokens</code> taken from your own runs. Take thresholds from a few weeks of history rather than guessing (<a href="https://cronradar.com/blog/scheduled-job-silent-success">choosing thresholds</a>).</p>
<p>CronRadar rejects a body it can't accept, such as a sixth value or a string, with a 400 and records no completion, and since the next <code>start</code> still arrives on schedule, that can go unnoticed. Send one completion by hand with <code>curl --fail-with-body</code> before relying on it. CronRadar also rejects a <code>fail</code> message longer than 1,000 characters with a 400 and records no failure. Keep prompts, summaries, and diffs out of check-ins, as <a href="https://cronradar.com/blog/monitoring-scheduled-ai-agent-runs">monitoring scheduled AI agent runs</a> explains. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.</p>
<h2>Frequently Asked Questions</h2>
<h3>How do I run Claude Code from a cron job?</h3>
<p>Call <code>claude --bare -p</code> from a wrapper script that loads <code>ANTHROPIC_API_KEY</code> from a file readable only by the job's account, sets <code>--permission-mode dontAsk</code> with an <code>--allowedTools</code> list, caps the run with <code>--max-turns</code> and <code>--max-budget-usd</code>, and wraps it in <code>timeout</code>. Use <code>--output-format json</code> and check that <code>subtype</code> is <code>success</code> and <code>is_error</code> is <code>false</code>, not just the exit code.</p>
<h3>How does Claude Code authenticate without a browser?</h3>
<p>With an API key in <code>ANTHROPIC_API_KEY</code>, which non-interactive runs always use when it's set. For a subscription, <code>claude setup-token</code> prints a one-year OAuth token for <code>CLAUDE_CODE_OAUTH_TOKEN</code>, but <code>--bare</code> doesn't read it and it's tied to one person's subscription, so an API key suits shared, scheduled jobs better.</p>
<h3>How do I run Codex CLI non-interactively?</h3>
<p>Use <code>codex exec "&#x3C;prompt>"</code> inside a Git repository, with <code>CODEX_API_KEY</code> set only for that command. It runs read-only unless you pass <code>--sandbox workspace-write</code>, never waits for approvals, and exits 1 when its turn fails. Add <code>--json</code> for an event stream and <code>--output-schema</code> with <code>-o</code> for a final answer your script can parse.</p>
<h3>How do I stop a scheduled agent run from running too long or costing too much?</h3>
<p>Claude Code has <code>--max-turns</code> and <code>--max-budget-usd</code>; the budget is a client-side estimate that one response, plus any subagents still running, can overshoot, so leave headroom. Codex 0.166.0 has neither flag. For both, <code>timeout</code> bounds wall-clock time and exits 124 when it stops the agent, or 137 if it had to send KILL, and outcome checks on turns or cost alert well before the hard stop.</p>
<h3>Why did my scheduled agent run exit 0 but change nothing?</h3>
<p>The agent finished its turn without doing the work: its tool calls were denied, auto mode fell back or kept blocking, it decided there was nothing to do, or it described changes it never made. Have it return a verdict in a JSON schema, compare that with <code>git status</code>, and count <code>permission_denials</code> on Claude Code.</p>
<h3>How do I get alerted when a scheduled agent run fails or does nothing?</h3>
<p>Send a <code>start</code> check-in before the agent runs and <code>complete</code> or <code>fail</code> after, so a missed, failed, or hung run alerts. Attach counts such as files changed, denials, turns, and cost, plus a <code>blocked</code> flag, to the completion, and configure outcome checks on them, so a run that exits 0 without doing its job still opens an incident.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>run claude code on a schedule</category>
      <category>claude -p cron job</category>
      <category>codex exec cron</category>
      <category>claude code action scheduled workflow</category>
      <category>unattended coding agent run</category>
    </item>
    <item>
      <title><![CDATA[UptimeRobot Heartbeat Monitoring for Cron Jobs: Limits and Plans]]></title>
      <link>https://cronradar.com/blog/uptimerobot-heartbeat-monitoring</link>
      <guid>https://cronradar.com/blog/uptimerobot-heartbeat-monitoring</guid>
      <description><![CDATA[How UptimeRobot heartbeat monitors watch cron jobs: interval and grace limits, which paid plans include them, alerts, a wrapper, and how CronRadar differs.]]></description>
      <content:encoded><![CDATA[<p><strong>UptimeRobot monitors cron jobs with a Heartbeat monitor, also called a cron job monitor: the job sends a GET or POST request to the monitor's unique URL, and UptimeRobot marks the monitor down when no request arrives within the interval plus the grace period.</strong> The interval is a duration from 1 minute to 31 days, not a cron expression, and the documented request carries no result, so a failed run shows up only as a missing request. Heartbeats aren't on the Free plan; the cheapest plan with them is Solo, at $13 a month billed monthly or $12 a month billed annually, for 10 monitors.</p>
<p>This guide covers UptimeRobot's hosted service and its v3 API, spec version 3.0 in the <a href="https://cdn.uptimerobot.com/api/openapi.yaml">published OpenAPI file</a>, as documented in its <a href="https://help.uptimerobot.com/en/">help center</a> and on its <a href="https://uptimerobot.com/pricing/">pricing page</a> (checked October 30, 2026). For why a job needs a heartbeat at all, see <a href="https://cronradar.com/blog/cron-dead-mans-switch">the dead man's switch guide</a>; for what uptime checks miss, see <a href="https://cronradar.com/blog/cron-monitoring-vs-uptime-monitoring">cron monitoring vs uptime monitoring</a>. The comparison at the end covers UptimeRobot's heartbeat feature only, not its uptime checks or status pages.</p>
<h2>How a Heartbeat Monitor Decides a Job Is Down</h2>
<p>Create the monitor with <strong>+ Add New Monitor</strong> and the Heartbeat type, then set a Friendly Name, the interval, the grace period, and the alert contacts. UptimeRobot generates the monitor's unique URL when you create it (<a href="https://help.uptimerobot.com/en/articles/11358364-how-to-create-your-first-monitor-on-uptimerobot-quick-setup-guide">quick setup guide</a>). The help center's examples use URLs of the form <code>https://heartbeat.uptimerobot.com/m794yyyyyyyy-xxxxxxxxxxxxxxx</code> (<a href="https://help.uptimerobot.com/en/articles/17662754-how-to-send-http-requests-to-a-heartbeat-monitor">sending requests</a>), and the API returns the URL as <code>heartbeatUrl</code> (<a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>).</p>
<p>What the documentation says about requests and deadlines:</p>
<ul>
<li><strong>Methods.</strong> UptimeRobot receives only GET and POST requests to the heartbeat URL (<a href="https://uptimerobot.com/help/monitor-status-is-wrong/">monitor status troubleshooting</a>).</li>
<li><strong>The deadline.</strong> If a request doesn't arrive within the interval plus the grace period, the monitor goes down and you're alerted (<a href="https://uptimerobot.com/cron-job-monitoring/">cron job monitoring</a>). The API describes the grace period as the time "to wait past the expected heartbeat before alerting" (<a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>).</li>
<li><strong>A job that never reports.</strong> The <a href="https://help.uptimerobot.com/en/articles/11358364-how-to-create-your-first-monitor-on-uptimerobot-quick-setup-guide">quick setup guide</a> says a heartbeat monitor that receives no request is marked down, so a job that was never installed is caught too.</li>
<li><strong>What it records.</strong> Up or down status, the time of the last heartbeat, uptime percentage, and incident history (<a href="https://uptimerobot.com/cron-job-monitoring/">cron job monitoring</a>). Run duration isn't on that list.</li>
</ul>
<h3>The Request Carries No Result</h3>
<p>The heartbeat documentation describes one kind of request: a GET or POST to the URL. It documents no parameter for reporting a failure, a start, or a message. A job therefore reports success by sending the request and failure by not sending it, and UptimeRobot learns of a failed run at the same moment it would learn of a run that never started: when the interval plus grace runs out. The alert can't say which of the two happened or include the job's exit code.</p>
<h3>Interval and Grace Limits</h3>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Documented range</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<tr>
<td>Interval</td>
<td>1 minute to 31 days; the API takes seconds, at most 2,678,400</td>
<td><a href="https://uptimerobot.com/cron-job-monitoring/">cron job monitoring</a>, <a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a></td>
</tr>
<tr>
<td>Grace period</td>
<td>0 to 86,400 seconds (24 hours) in the API</td>
<td><a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a></td>
</tr>
<tr>
<td>Notification delay (<code>threshold</code>)</td>
<td>Minutes to wait after the monitor goes down before notifying a contact; paid plans</td>
<td><a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>, <a href="https://help.uptimerobot.com/en/articles/11361289-recurring-postponed-notifications-in-uptimerobot">recurring notifications</a></td>
</tr>
<tr>
<td>Repeat (<code>recurrence</code>)</td>
<td>Repeat a contact's notification every N minutes; paid plans</td>
<td><a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>, <a href="https://help.uptimerobot.com/en/articles/11361289-recurring-postponed-notifications-in-uptimerobot">recurring notifications</a></td>
</tr>
</tbody>
</table>
<h2>An Interval, Not a Cron Schedule</h2>
<p>The help center tells you to match the crontab entry to the monitor: replace the 5 in <code>*/5</code> "with your monitor's interval in minutes" (<a href="https://help.uptimerobot.com/en/articles/17662754-how-to-send-http-requests-to-a-heartbeat-monitor">sending requests</a>). The API's heartbeat create schema has <code>interval</code> and <code>gracePeriod</code> and no schedule field (<a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>). Interval plus grace has to exceed the longest normal gap between two requests from the job, and any slack beyond that is time a missed run goes unreported. What that means for common schedules:</p>
<ul>
<li><strong>Daily jobs: the period goes in the interval, the variation in the grace.</strong> A backup that starts at 03:00, takes 10 to 40 minutes, and sends the request when it finishes has gaps of up to 24 hours 30 minutes between requests. An interval of 1,440 minutes (24 hours) with a 45-minute grace leaves 15 minutes of headroom. On a host whose clock follows daylight saving time, the night the clocks go back adds an hour, so use a grace of 1 hour 45 minutes there. <a href="https://cronradar.com/blog/uptime-kuma-cron-monitoring">The Uptime Kuma guide</a> works through the same arithmetic for a single interval, and <a href="https://cronradar.com/blog/cron-grace-period">the grace period guide</a> shows how to derive the numbers from measured runs.</li>
<li><strong>Weekday-only and other irregular schedules</strong> need an interval that covers their longest gap, such as the weekend, which delays the alert for a run missed on any weekday; <a href="https://cronradar.com/blog/cron-monitoring-vs-uptime-monitoring">cron monitoring vs uptime monitoring</a> works through the <code>0 6 * * 1-5</code> case.</li>
<li><strong>Monthly jobs</strong> on the 1st have gaps of up to 31 days, which is the maximum interval, so only the grace is left to cover run time and start delay.</li>
<li><strong>Quarterly and other jobs that run less often than monthly</strong> don't fit: a quarter's gap of roughly 90 days is longer than the 31-day maximum interval plus the 24-hour maximum grace, so the monitor would go down between every pair of runs.</li>
</ul>
<p>Leave the notification delay at 0 on heartbeat monitors. It's added to interval plus grace on every alert, and the grace already absorbs normal variation.</p>
<h2>Plans and Prices for Heartbeat Monitoring</h2>
<p>The pricing page's feature comparison marks Heartbeat monitoring as not included on Free and included on Solo, Team, and Scale, and Enterprise includes everything in Scale (<a href="https://uptimerobot.com/pricing/">pricing</a>, checked October 30, 2026). A heartbeat is one of the monitor types (<a href="https://help.uptimerobot.com/en/articles/11358364-how-to-create-your-first-monitor-on-uptimerobot-quick-setup-guide">quick setup guide</a>), and each plan's limit counts every monitor on the account (<a href="https://help.uptimerobot.com/en/articles/16160088-what-happens-when-you-downgrade-or-your-subscription-ends">downgrades</a>), so heartbeats share the count with HTTP, keyword, ping, and other monitors.</p>
<table>
<thead>
<tr>
<th>Plan</th>
<th>Monitors</th>
<th>Heartbeats</th>
<th>Billed monthly, per month</th>
<th>Billed annually, per month</th>
</tr>
</thead>
<tbody>
<tr>
<td>Free</td>
<td>50</td>
<td>No</td>
<td>$0</td>
<td>$0</td>
</tr>
<tr>
<td>Solo</td>
<td>10</td>
<td>Yes</td>
<td>$13</td>
<td>$12</td>
</tr>
<tr>
<td>Solo</td>
<td>50</td>
<td>Yes</td>
<td>$28</td>
<td>$24</td>
</tr>
<tr>
<td>Team</td>
<td>100</td>
<td>Yes</td>
<td>$39 or $46</td>
<td>$33 or $39</td>
</tr>
<tr>
<td>Scale</td>
<td>200</td>
<td>Yes</td>
<td>$83 or $98</td>
<td>$67 or $79</td>
</tr>
<tr>
<td>Scale</td>
<td>500</td>
<td>Yes</td>
<td>$193 or $228</td>
<td>$162 or $192</td>
</tr>
<tr>
<td>Enterprise</td>
<td>Custom</td>
<td>Yes</td>
<td>Quote</td>
<td>Quote</td>
</tr>
</tbody>
</table>
<p>Team and Scale prices changed between loads of the page on October 30, 2026: from the same US connection, some loads served the lower set of figures and others the higher, while Solo's prices were the same in every load. UptimeRobot's <a href="https://uptimerobot.com/pricing.md">pricing.md</a> says its anonymous pricing endpoint is geo-banded and asks for prices to be checked against the live page before they're quoted, so check the page before you choose a plan. The page's annual totals follow whichever set it serves: $144 a year for Solo with 10 monitors, $396 or $468 for Team, and $804 or $948 for Scale with 200, and the page says annual billing saves about 15%. The page's feature comparison also lists other plan differences that affect heartbeats:</p>
<ul>
<li><strong>Notification channels.</strong> Email, SMS, voice call, Email2SMS, the mobile app, and Google Chat, Discord, Pushover, Pushbullet, and Splunk on every plan; Slack, Mattermost, Telegram, and Microsoft Teams from Solo; webhook, Zapier, and PagerDuty from Team. The mobile app is one of the personal channels, sending push notifications (<a href="https://help.uptimerobot.com/en/articles/11360978-understanding-notification-channels-in-uptimerobot">notification channels</a>). Solo includes 10 to 20 SMS or voice call credits, Team 30, and Scale 50 to 200, and credits don't renew with the plan.</li>
<li><strong>Recurring and delayed notifications, and maintenance windows,</strong> are on paid plans, so every plan that has heartbeats has them.</li>
<li><strong>Seats.</strong> The table lists no included login seats on Solo, 3 on Team, and 5 on Scale.</li>
<li><strong>Data retention.</strong> 12 months on Solo, 24 months on Team and Scale (<a href="https://help.uptimerobot.com/en/articles/11360873-what-is-uptimerobot-data-retention-and-how-does-it-work-in-different-plans">data retention</a>).</li>
</ul>
<h2>Notifications and Maintenance Windows</h2>
<ul>
<li><strong>Contacts are assigned per monitor.</strong> A contact that isn't assigned to a monitor gets no alerts from it; assign contacts one monitor at a time or with bulk actions. Each contact can receive Up and Down alerts, both on by default (<a href="https://help.uptimerobot.com/en/articles/11360978-understanding-notification-channels-in-uptimerobot">notification channels</a>).</li>
<li><strong>Delay and repeat are set per contact on each monitor,</strong> in minutes: the delay is how long to wait after a failure before alerting, and the repeat is how often the alert recurs (<a href="https://help.uptimerobot.com/en/articles/11361289-recurring-postponed-notifications-in-uptimerobot">recurring notifications</a>).</li>
<li><strong>Alert storm protection</strong> is a beta on paid plans and off by default. When the number of monitors that go down within a time window (5 minutes by default) reaches your threshold, it suppresses their individual Down notifications, sends one storm notification with the number of monitors affected and a link to the incidents, and cancels pending delayed and repeat notifications while the storm lasts (<a href="https://help.uptimerobot.com/en/articles/15433157-alert-storm-protection-in-uptimerobot">alert storm protection</a>). With it on, a job that stops during a wider outage gets no Down notification of its own.</li>
<li><strong>Maintenance windows treat heartbeats differently.</strong> Most monitor types leave the window with the status they had going in. A heartbeat monitor that is already down when a window starts stays down for the whole window, even after its job resumes, and turns up only on the first check after the window ends; the help center's advice is to start the window before you stop the job. Windows follow the account's time zone, and checks run less often inside one (<a href="https://help.uptimerobot.com/en/articles/11360884-what-is-a-maintenance-window-and-how-to-use-it-in-uptimerobot">maintenance windows</a>).</li>
</ul>
<h2>A Cron Wrapper That Sends the Heartbeat</h2>
<p>Set up the monitor first:</p>
<ol>
<li>Create a Heartbeat monitor named after the job, with the interval and grace from the sizing above, and assign at least one alert contact.</li>
<li>Save the heartbeat URL in a file owned by the job's account with mode 600, here <code>/etc/uptimerobot/nightly-backup.url</code>. The help center's examples put the URL in the crontab line and send nothing else with it, so the URL alone marks the job as having run. Keep it out of crontabs and repositories.</li>
</ol>
<p>The wrapper runs the job and sends the heartbeat only when the job succeeds:</p>
<pre><code class="language-bash">#!/bin/sh
# /opt/backup-uptimerobot.sh: run the backup, then send the UptimeRobot heartbeat if it succeeded.
# The file holds the monitor's heartbeat URL and is readable only by this account.
heartbeat_url=$(cat /etc/uptimerobot/nightly-backup.url 2>/dev/null)

/opt/backup.sh
rc=$?

if [ "$rc" -eq 0 ] &#x26;&#x26; [ -n "$heartbeat_url" ]; then
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    "$heartbeat_url" || true
fi
exit "$rc"
</code></pre>
<p>Schedule it with <code>0 3 * * * /opt/backup-uptimerobot.sh</code>. A request means success, so a non-zero exit sends nothing and the monitor goes down once interval plus grace pass. <code>--max-time 5</code> caps the request, <code>|| true</code> ignores a failed one, and the script exits with the backup's own code, so an unreachable UptimeRobot can't fail or stall the job; <code>--show-error</code> still writes a failed request to the job's stderr. The help center's curl example passes <code>--insecure</code>, which skips TLS certificate verification (<a href="https://curl.se/docs/manpage.html#-k">curl manual</a>); this wrapper leaves verification on. <a href="https://cronradar.com/blog/cron-shell-script-best-practices">Cron shell script best practices</a> covers keeping the job's exit code honest.</p>
<p>A hung run looks the same as a missed one: no request, then an alert when interval plus grace pass, while the hung process keeps running. To end a hung run at a time you choose, put a time limit around the job; <a href="https://cronradar.com/blog/cron-job-timeout">cron job timeouts</a> covers GNU <code>timeout</code> and its exit codes.</p>
<h3>Creating Heartbeat Monitors Through the API</h3>
<p>For many jobs, or an AI agent setting monitors up, the v3 API creates the same monitor. <code>POST /v3/monitors</code> with the heartbeat body requires <code>friendlyName</code>, <code>type</code>, and <code>interval</code>, takes <code>gracePeriod</code> optionally, both in seconds, and returns the monitor including <code>heartbeatUrl</code>; each entry in <code>assignedAlertContacts</code> needs <code>alertContactId</code>, <code>threshold</code>, and <code>recurrence</code>, and <code>GET /v3/user/alert-contacts</code> lists contacts with their <code>id</code> (<a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>). Requests send an API key, created in the dashboard's API section (<a href="https://help.uptimerobot.com/en/articles/11620152-how-to-use-uptimerobot-s-api">API help</a>), as a bearer token (<a href="https://cdn.uptimerobot.com/api/openapi.yaml">openapi.yaml</a>).</p>
<pre><code class="language-bash"># UPTIMEROBOT_API_KEY comes from your secret store.
# 12345 stands for an alert contact id returned by GET /v3/user/alert-contacts.
curl --fail-with-body --silent --show-error \
  --request POST "https://api.uptimerobot.com/v3/monitors" \
  --header "Authorization: Bearer $UPTIMEROBOT_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "friendlyName": "nightly-backup",
    "type": "HEARTBEAT",
    "interval": 86400,
    "gracePeriod": 2700,
    "assignedAlertContacts": [
      { "alertContactId": 12345, "threshold": 0, "recurrence": 1440 }
    ]
  }'
</code></pre>
<p>That is the daily backup from the sizing above: a 24-hour interval, a 45-minute grace, no notification delay, and a repeat every 1,440 minutes (24 hours). Save the returned <code>heartbeatUrl</code> in the job's URL file. Each job is still its own create call and its own URL to distribute; the URL exists only once the monitor does, so a job can't register itself with its first request.</p>
<h2>UptimeRobot Heartbeats and CronRadar Compared</h2>
<p>CronRadar is a hosted monitor for scheduled jobs only. UptimeRobot facts below were checked October 30, 2026.</p>
<table>
<thead>
<tr>
<th></th>
<th>UptimeRobot Heartbeat monitor</th>
<th>CronRadar</th>
</tr>
</thead>
<tbody>
<tr>
<td>When a report is due</td>
<td>Interval, 1 minute to 31 days</td>
<td>Five-field cron schedule in UTC; each check-in sets the next expected time from the schedule</td>
</tr>
<tr>
<td>Slack before alerting</td>
<td>Grace, 0 to 86,400 seconds, plus any per-contact delay</td>
<td>Grace period in seconds, 60 minimum: <code>warning</code> inside it, <code>critical</code> after</td>
</tr>
<tr>
<td>Signals from a job</td>
<td>One GET or POST request</td>
<td>Heartbeat, or <code>start</code>, <code>complete</code>, and <code>fail</code> with a <code>message</code> of up to 1,000 characters</td>
</tr>
<tr>
<td>A failed run</td>
<td>No documented failure signal; seen when interval plus grace pass</td>
<td><code>fail</code> opens an incident immediately</td>
</tr>
<tr>
<td>Run duration</td>
<td>Not among the recorded metrics</td>
<td>Recorded from a <code>start</code> and <code>complete</code> pair</td>
</tr>
<tr>
<td>A hung run</td>
<td>Seen as a missed request once interval plus grace pass</td>
<td>Stuck: a <code>start</code> with no finish once the next expected run plus grace passes</td>
</tr>
<tr>
<td>Result checks</td>
<td>None documented</td>
<td>Up to five numeric or boolean outcomes checked on <code>complete</code></td>
</tr>
<tr>
<td>Creating monitors</td>
<td>Dashboard or v3 API, then copy the URL into the job</td>
<td>A check-in that carries <code>schedule</code>, the dashboard, or Hangfire and Quartz.NET discovery</td>
</tr>
<tr>
<td>Repeat alerts</td>
<td>Per-contact repeat every N minutes</td>
<td>First alert, at most one reminder every 24 hours, and a recovery message; acknowledging stops reminders</td>
</tr>
<tr>
<td>Alert channels</td>
<td>Email, SMS, voice, Email2SMS, mobile app, Discord, Google Chat, Pushover, Pushbullet, Splunk on all plans; Slack, Teams, Telegram, Mattermost from Solo; webhook, Zapier, PagerDuty from Team</td>
<td>Email, Slack, webhook, Discord, PagerDuty on every plan, Free included</td>
</tr>
<tr>
<td>History</td>
<td>12 months on Solo, 24 on Team and Scale</td>
<td>Free: 30 days of runs, 90 days of alerts. Grow: 90 and 180 days</td>
</tr>
<tr>
<td>Cheapest plan with it</td>
<td>Solo: 10 monitors, $13 a month, or $12 billed annually</td>
<td>Free: up to 10 active monitors</td>
</tr>
</tbody>
</table>
<h3>What Each Costs by Job Count</h3>
<p>Monthly list prices before tax, assuming the UptimeRobot account has no other monitors and every CronRadar job is in one schedule class. These compare job counts, not equivalent products: UptimeRobot's monitors can also be uptime checks, and CronRadar doesn't charge per seat.</p>
<table>
<thead>
<tr>
<th align="right">Heartbeat jobs</th>
<th>UptimeRobot plan</th>
<th align="right">UptimeRobot, monthly / annual billing</th>
<th align="right">CronRadar, daily or hourly jobs (Standard)</th>
<th align="right">CronRadar, every 5 to 59 minutes (Frequent)</th>
</tr>
</thead>
<tbody>
<tr>
<td align="right">10</td>
<td>Solo, 10</td>
<td align="right">$13 / $12</td>
<td align="right">$0</td>
<td align="right">$0</td>
</tr>
<tr>
<td align="right">25</td>
<td>Solo, 50</td>
<td align="right">$28 / $24</td>
<td align="right">$7.50</td>
<td align="right">$15</td>
</tr>
<tr>
<td align="right">50</td>
<td>Solo, 50</td>
<td align="right">$28 / $24</td>
<td align="right">$20</td>
<td align="right">$40</td>
</tr>
<tr>
<td align="right">100</td>
<td>Team</td>
<td align="right">$39 or $46 / $33 or $39</td>
<td align="right">$45</td>
<td align="right">$90</td>
</tr>
<tr>
<td align="right">200</td>
<td>Scale, 200</td>
<td align="right">$83 or $98 / $67 or $79</td>
<td align="right">$95</td>
<td align="right">$190</td>
</tr>
<tr>
<td align="right">500</td>
<td>Scale, 500</td>
<td align="right">$193 or $228 / $162 or $192</td>
<td align="right">$245</td>
<td align="right">$490</td>
</tr>
</tbody>
</table>
<p>Where a cell shows two UptimeRobot prices, the pricing page served both on October 30, 2026, as described in the plans section above. For daily or hourly jobs, CronRadar's list price is lower than UptimeRobot's on both billing periods up to 50 jobs. At 100 and 200 jobs it is lower only than UptimeRobot's higher monthly price: UptimeRobot's annual billing, and its lower monthly price, cost less. At 500, UptimeRobot is lower on both. For jobs every 5 to 59 minutes, UptimeRobot is lower from 50 jobs. Above 500 monitors UptimeRobot quotes Enterprise; CronRadar has no monitor limit.</p>
<p>The CronRadar figures are Grow list prices: the first 10 active monitors are free, fastest schedules first, and each further monitor costs $1 a month on a Frequent schedule (5 minutes to under an hour) or $0.50 on a Standard one (hourly or less often), billed in arrears on the month's peak (<a href="https://cronradar.com/pricing">pricing</a>).</p>
<h3>The Same Backup Reporting to CronRadar</h3>
<p>Shell scripts have no CronRadar SDK. The Node.js, Python, Celery, PHP, and Laravel SDKs are also unavailable, so those jobs report with HTTP check-ins too, and HTTP check-ins don't discover jobs automatically. <a href="https://cronradar.com/blog/uptime-kuma-cron-monitoring#uptime-kuma-and-cronradar-compared">The Uptime Kuma guide</a> has the full CronRadar wrapper for this backup: it reads the API key from a file only the job's account can read, sends it with HTTP Basic auth as the username with an empty password, reports <code>start</code> before the backup and <code>complete</code> or <code>fail</code> after it, and keeps the same <code>--max-time 5</code> and <code>|| true</code> guards as the UptimeRobot wrapper above.</p>
<p>Two things differ from the UptimeRobot monitor. The failure is sent, with the exit code in its <code>message</code>, and alerts immediately instead of when interval plus grace run out. And the deadline follows the schedule rather than the last request, so the grace covers only the start: with <code>schedule=0 3 * * *</code> and <code>gracePeriod=900</code>, a missed run turns the monitor <code>critical</code> once 03:15 UTC passes, however long the previous run took, where the UptimeRobot monitor above needs a 45-minute grace to absorb the variation in the backup's run time. CronRadar reads the schedule in UTC; <a href="https://docs.cronradar.com/schedules">schedule syntax</a> covers converting a local schedule and one difference from Linux cron when both day fields are restricted.</p>
<p>The same guide covers CronRadar's limits: stuck detection works per job rather than per run, and a monitor created by its first check-in can't flag a job that never runs unless you create it in the dashboard first. The .NET packages are the only installable SDKs (CronRadar 1.0.13, CronRadar.Hangfire 0.1.0, CronRadar.Quartz 0.1.0, checked September 20, 2026): CronRadar 1.0.13 can't send outcome values, and CronRadar.Hangfire 0.1.0 can report a failure before Hangfire finishes retrying. CronRadar has no SMS or voice call alerts, offers no contractual SLA, and publishes no availability figure.</p>
<h3>When UptimeRobot Heartbeats Fit</h3>
<ul>
<li><strong>You already pay for UptimeRobot</strong> and your plan has spare monitors: heartbeats share the notifications and dashboard you use for uptime checks.</li>
<li><strong>Your jobs run on a fixed interval</strong> between 1 minute and 31 days, such as every 15 minutes or every hour, so the job's period can be the monitor's interval.</li>
<li><strong>You want SMS or voice call alerts</strong> for a missed job, which CronRadar doesn't offer.</li>
<li><strong>You run many jobs,</strong> where UptimeRobot's list price in the table above is lower: from 50 jobs on schedules every 5 to 59 minutes, from 100 daily or hourly jobs on annual billing or at the lower monthly price the page sometimes shows, and at 500 daily or hourly jobs on either billing.</li>
</ul>
<h3>When a Schedule-Aware Cron Monitor Fits</h3>
<ul>
<li><strong>Schedules an interval can't express:</strong> weekdays only, fixed times several times a day, or anything less frequent than monthly.</li>
<li><strong>Failures should alert when they happen,</strong> with the job's message, instead of when the interval runs out.</li>
<li><strong>You want start and finish signals,</strong> for stuck runs, checks on reported results, and run duration, which isn't among UptimeRobot's recorded heartbeat metrics.</li>
<li><strong>Monitors should appear from a job's first check-in</strong> or from Hangfire and Quartz.NET discovery rather than one form or API call per job.</li>
<li><strong>You need webhook or PagerDuty alerts</strong> for a handful of jobs without moving to Team.</li>
</ul>
<p><a href="https://cronradar.com/comparisons/best-cron-monitoring-tools">Cron monitoring tools compared</a> puts hosted options side by side.</p>
<h2>Frequently Asked Questions</h2>
<h3>Does UptimeRobot monitor cron jobs?</h3>
<p>Yes, with a Heartbeat monitor, which the product pages also call a cron job monitor. The job sends a GET or POST request to the monitor's URL, and UptimeRobot marks it down when no request arrives within the interval plus the grace period.</p>
<h3>Is UptimeRobot heartbeat monitoring free?</h3>
<p>No. The Free plan doesn't include heartbeat monitors. As of October 30, 2026, the cheapest plan with them is Solo: $13 a month billed monthly, or $12 a month billed annually, for 10 monitors of any type.</p>
<h3>What is the longest interval for an UptimeRobot heartbeat monitor?</h3>
<p>31 days, which the API expresses as 2,678,400 seconds, plus a grace period of up to 86,400 seconds. Jobs with longer gaps between runs, such as quarterly ones, don't fit in one heartbeat monitor.</p>
<h3>Can a cron job report a failure to UptimeRobot?</h3>
<p>Not through the documented heartbeat request, which is a GET or POST to the URL with no status or message. Skip the request when the job fails, and the monitor goes down once the interval plus grace pass.</p>
<h3>Can an UptimeRobot heartbeat monitor use a cron expression?</h3>
<p>No. The monitor takes an interval and a grace period, and the help center tells you to set the crontab entry to match the interval. For a schedule such as weekdays at 06:00, the interval has to cover the longest gap, the weekend.</p>
]]></content:encoded>
      <pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>uptimerobot heartbeat monitoring</category>
      <category>uptimerobot cron job monitoring</category>
      <category>uptimerobot heartbeat url</category>
      <category>uptimerobot heartbeat grace period</category>
      <category>uptimerobot heartbeat pricing</category>
    </item>
    <item>
      <title><![CDATA[AI Agent Cron Monitoring Setup: Scoped Access, Verified Runs]]></title>
      <link>https://cronradar.com/blog/ai-agent-cron-monitoring-setup</link>
      <guid>https://cronradar.com/blog/ai-agent-cron-monitoring-setup</guid>
      <description><![CDATA[How to have an AI coding agent add cron job monitoring safely: what access to grant, which credentials stay out of chat, what to review, and how to verify.]]></description>
      <content:encoded><![CDATA[<p><strong>To set up cron job monitoring with an AI coding agent safely, let the agent change code but keep it away from your deployment secret, give it workspace access only when a task needs it, and treat its work as unverified until a real scheduled run reports a result.</strong> Coding agents such as Claude Code, Cursor, Codex, and GitHub Copilot's agent mode are good at the tedious part: finding every scheduled job in a repository and wiring the same reporting pattern into each one. They are just as capable of producing monitoring that compiles and never reports, or that quietly changes how a job retries or exits. This guide uses CronRadar's agent setup as the worked example; the access, review, and verification steps apply to any monitoring service.</p>
<h2>Choose the Agent's Access Before You Paste Anything</h2>
<p>CronRadar's <a href="https://docs.cronradar.com/agent-quickstart">agent quickstart</a> offers two ways to start. They differ in one thing: whether the agent can act on your CronRadar workspace. Neither needs a CLI or an agent skill installed.</p>
<h3>The Public Prompt: Code Changes Only</h3>
<pre><code class="language-text">Fetch and follow the instructions to set up CronRadar monitoring from https://docs.cronradar.com/agent-setup/prompt.md
</code></pre>
<p>This prompt contains no credentials and grants no workspace access. The agent edits code; you create the application and its key in the console's <strong>Setup</strong> tab and store the key yourself. The public instructions tell an agent without access to send you to the console for a private prompt, so if you want code changes only, say so, as the prompts further down do. The <a href="https://docs.cronradar.com/ai-setup">AI setup page</a> has a longer code-only prompt that spells this out.</p>
<h3>The Private Setup Prompt: Temporary Workspace Access</h3>
<p>At <a href="https://app.cronradar.com/assistant">app.cronradar.com/assistant</a> you choose one workspace, the permissions, and an expiry, then copy a private prompt that carries a temporary token. Read access supports investigation, write access allows setup, and delete is a separate choice. Access lasts one hour by default and up to 24 hours, can be revoked at any time, and never exceeds your own workspace role, which is checked on every request. Creating applications and jobs needs a Member role with write access; updating them or creating a deployment key needs Admin with write access.</p>
<p>The agent needs an HTTP or code-execution tool to use the token; a chat-only assistant can explain setup but can't call the management API.</p>
<h3>Which One to Use</h3>
<table>
<thead>
<tr>
<th>Task</th>
<th>Access</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td>Add monitoring code to a repository</td>
<td>Public prompt</td>
<td>The agent never holds a credential; you control the key</td>
</tr>
<tr>
<td>See what is already monitored before changing code</td>
<td>Private prompt, read</td>
<td>Prevents duplicate applications and jobs</td>
</tr>
<tr>
<td>Investigate a late or failed job</td>
<td>Private prompt, read</td>
<td>An investigation should not be able to change anything</td>
</tr>
<tr>
<td>Let the agent create applications and jobs, then read runs</td>
<td>Private prompt, read and write</td>
<td>Setup and evidence in one session</td>
</tr>
<tr>
<td>Remove old monitors on purpose</td>
<td>Private prompt with delete, for that one task</td>
<td>Deletion also requires confirming the resource's current name</td>
</tr>
</tbody>
</table>
<p>Pick the shortest expiry that covers the task. A task that outlasts its grant gets fresh access from the console, not a silently extended one.</p>
<h2>Keep the Two Credentials Apart</h2>
<p>Two credentials are in play, and the most damaging mistake is letting one stand in for the other.</p>
<table>
<thead>
<tr>
<th>Credential</th>
<th>What it does</th>
<th>Lifetime</th>
<th>Where it belongs</th>
</tr>
</thead>
<tbody>
<tr>
<td>Temporary token in the private prompt</td>
<td>Reads or manages one workspace through the management API</td>
<td>1 hour by default, up to 24; revocable sooner</td>
<td>The agent's memory for one session</td>
</tr>
<tr>
<td><code>CRONRADAR_API_KEY</code>, an ingestion key</td>
<td>Reports check-ins from one deployed application to <code>cron.life</code></td>
<td>Its own expiry and revocation</td>
<td>The deployment's secret manager</td>
</tr>
</tbody>
</table>
<p>The rules that follow from that split:</p>
<ul>
<li><strong>The ingestion key never enters the chat.</strong> It is shown once when created. Save it straight into the deployment's secret manager as <code>CRONRADAR_API_KEY</code>; the agent doesn't need the value to write code that reads it.</li>
<li><strong>The agent creates a key only if it can store it safely.</strong> CronRadar's instructions let an agent with Admin and write access create an ingestion key only to save it through an authorized secret-store operation, never printing it. Without such a tool, create the key in the console.</li>
<li><strong>Assume the transcript outlives the token.</strong> Anyone holding the private prompt can use it until it expires or is revoked, and your assistant provider may retain the conversation after expiry. Keep the expiry short, and never paste the prompt into a shared channel or ticket.</li>
<li><strong>Revoke when you finish, then check what remains.</strong> Expiring or revoking access does not undo the agent's changes or revoke ingestion keys it created. Applications and jobs stay in the workspace, and created keys keep deployed monitoring running until their own expiry or revocation. Review them in the application's <strong>Setup</strong> tab.</li>
<li><strong>Use one application and key per environment.</strong> Production, staging, and development each get their own, so a staging host can't update production monitors.</li>
</ul>
<h2>What a Good Agent Run Looks Like</h2>
<p>A careful run follows the same order whichever prompt you used. CronRadar's task recipes describe it for the agent; use it as your yardstick when reading the transcript.</p>
<ol>
<li><strong>It reads before it writes.</strong> With workspace access, its first call is <code>GET /context</code>, to confirm the workspace and effective permissions match your request. Then it lists existing applications and jobs, matching environment and stable job key before proposing anything new. A job's management ID and its monitor key differ; check-ins use the key.</li>
<li><strong>It keeps environments in separate applications.</strong> It reuses the application for this environment or creates one whose environment field describes the actual deployment.</li>
<li><strong>It finds the process that actually runs the scheduler.</strong> Monitoring belongs where jobs execute: the host with the crontab, the worker service that runs Hangfire Server, the container a Kubernetes CronJob starts. An integration added to a web project does nothing if the jobs run in a separate worker.</li>
<li><strong>It checks availability before installing.</strong> Repository docs can describe a release that isn't published yet. As of September 20, 2026, the installable packages are <code>CronRadar</code> 1.0.13 (.NET), <code>CronRadar.Hangfire</code> 0.1.0, and <code>CronRadar.Quartz</code> 0.1.0. For Node.js, Python, Celery, PHP, Laravel, and stacks such as Go, Ruby, or plain shell, there is no usable CronRadar SDK and no auto-discovery, so each job reports with its own HTTP check-in. Registries do list <code>cronradar</code> (npm, PyPI) and <code>cronradar-celery</code> (PyPI), but the docs mark them unavailable, so an agent that finds them in a search shouldn't install them.</li>
<li><strong>It lets discovery do its job.</strong> Framework integrations discover recurring jobs themselves; the base SDK and HTTP check-ins need an explicit report per job. If discovery misses a job, the fix is in the integration, not a duplicate created through the management API.</li>
<li><strong>It preserves the job.</strong> Same schedule, time zone, retry behavior, and result. It builds and runs the tests, then tells you which process needs a deploy or restart. It doesn't deploy, deliberately fail production work, pause monitoring, or delete anything you didn't ask it to.</li>
<li><strong>It reports evidence, then hands access back.</strong> The summary separates what changed, what real execution evidence it saw, and what remains unverified, including alert delivery, and ends by telling you to revoke the grant.</li>
</ol>
<p>For a Hangfire application, a correct diff can be this small:</p>
<pre><code class="language-bash">dotnet add package CronRadar.Hangfire
</code></pre>
<pre><code class="language-csharp">// Program.cs
using CronRadar.Hangfire;
using Hangfire;

// Existing Hangfire setup stays as it was.
builder.Services.AddHangfire(config =>
    config.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();

// Added after AddHangfire(). The key comes from CRONRADAR_API_KEY.
builder.Services.AddCronRadarHangfire();
</code></pre>
<p>The integration reads Hangfire's recurring-job registry at startup, reconciles it every 15 minutes, and reports each execution's start, completion, or failure; <code>[SkipMonitor]</code> on a method opts a job out. Monitoring calls added inside each job method on top of this are duplicated reporting, not extra safety. <a href="https://cronradar.com/hangfire">CronRadar for Hangfire</a> covers what the integration watches.</p>
<h2>Prompts You Can Paste</h2>
<p>Each states the access boundary, what must not change, and what counts as done.</p>
<h3>Add Monitoring to Hangfire Jobs, Code Only</h3>
<pre><code class="language-text">Add CronRadar monitoring to the Hangfire recurring jobs in this repo using the
public setup instructions at https://docs.cronradar.com/agent-setup/prompt.md.
Don't request workspace access, and don't create, ask for, or print any keys.
I'll create the application and key in the console and store CRONRADAR_API_KEY
in our secret manager. Read the Hangfire guide's availability notice first and
use only the published package. Put the integration in the process that runs
Hangfire Server. Keep every schedule, time zone, retry policy, and job result
unchanged. Run the tests, then tell me what needs deploying and how I'll know
a real run reported.
</code></pre>
<h3>Add HTTP Check-Ins Where There Is No SDK</h3>
<pre><code class="language-text">Read https://docs.cronradar.com/agent-setup/prompt.md. Our scheduled jobs run
from node-cron in worker/index.js, and CronRadar has no available Node.js SDK,
so add HTTP check-ins to each job: start, then complete or fail. Don't install
any CronRadar package. Each check-in must time out after 5 seconds, ignore its
own errors, and leave the job's exception and exit code unchanged. Send the
five-field UTC equivalent of each schedule and read CRONRADAR_API_KEY from the
environment. Don't request workspace access or handle keys.
</code></pre>
<h3>Investigate a Late Job With Read-Only Access</h3>
<p>Create a private prompt with read access only, paste it, then add:</p>
<pre><code class="language-text">Using read-only access, find out why nightly-invoice-export in the production
application was late last night. Read the job, its recent check-ins, and its
alerts. Compare the expected schedule, time zone, and grace period with the
latest start and terminal events, and tell me whether it never reported, ran
late, failed explicitly, or started without finishing. Don't create, change,
pause, or delete anything. List what I should check in the scheduler itself.
</code></pre>
<p>The last sentence matters because the management API exposes check-ins and alerts, not application logs: the agent can say what CronRadar recorded, not why the job behaved that way.</p>
<h2>Review the Diff Before You Merge</h2>
<p><a href="https://cronradar.com/blog/reviewing-ai-generated-cron-jobs">Reviewing AI-generated cron jobs</a> covers these checks in depth; this is the short list for a monitoring change.</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>What to look for in the diff</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monitoring can't stall a run</td>
<td>Every hand-written check-in has a 5-second timeout. A .NET <code>HttpClient</code> waits <a href="https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.timeout">100 seconds by default</a>, and Python Requests <a href="https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts">never times out</a> unless you pass <code>timeout</code>.</td>
</tr>
<tr>
<td>Monitoring can't fail the job</td>
<td>Check-in errors are caught and ignored: <code>|| true</code> after <code>curl</code>, or a <code>try</code> around only the request.</td>
</tr>
<tr>
<td>Exceptions still propagate</td>
<td>After reporting <code>fail</code>, the original exception is re-thrown (<code>throw;</code> in C#, bare <code>raise</code> in Python), not swallowed.</td>
</tr>
<tr>
<td>Exit code unchanged</td>
<td>A shell wrapper captures <code>$?</code> right after the job and exits with it.</td>
</tr>
<tr>
<td>Retries unchanged</td>
<td>No new <code>[AutomaticRetry(Attempts = 0)]</code>, retry counts, or catch-and-return logic.</td>
</tr>
<tr>
<td>Schedule is five-field UTC</td>
<td>No seconds field, <code>@daily</code>, or <code>7</code> for Sunday; local times converted to UTC. A crontab line restricting both day fields is split into two job keys: CronRadar expects both to match, while cron runs on either.</td>
</tr>
<tr>
<td>No secrets in code</td>
<td>The key is read from <code>CRONRADAR_API_KEY</code>, never written into committed config, test fixtures, or a URL that ends up in logs.</td>
</tr>
<tr>
<td>No duplicate monitors</td>
<td>No per-job calls on top of framework discovery, no API-created jobs covering a discovery failure, no unexplained <code>[SkipMonitor]</code>.</td>
</tr>
</tbody>
</table>
<p>The retry row has a Hangfire-specific trap. Hangfire's automatic retry filter applies <a href="https://docs.hangfire.io/en/latest/background-processing/dealing-with-exceptions.html">10 retry attempts by default</a>, and the published <code>CronRadar.Hangfire</code> 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations (checked September 20, 2026). Reporting only after retries are exhausted requires the pending 0.2.1 release. An agent asked to quiet those early alerts may reach for <code>[AutomaticRetry(Attempts = 0)]</code>, which silences them by changing what the job does. Reject that change.</p>
<p>For the obvious secret leaks, scan the lines the agent added. No output is the result you want:</p>
<pre><code class="language-bash"># Added lines that look like a CronRadar key or a hardcoded bearer token.
git diff main...HEAD | grep -E '^\+.*(ck_app_|Bearer [A-Za-z0-9._~+/=-]{20,})'
</code></pre>
<p>This catches a pasted key, not one split across lines or encoded; a repository secret scanner, if you run one, remains the stronger control. For a shell wrapper that passes every row of the table, see the one in <a href="https://cronradar.com/blog/cron-dead-mans-switch">the dead man's switch guide</a>.</p>
<h2>Verify a Real Run Yourself</h2>
<p>A created monitor, a successful API call, an installed package, or a demo incident shows that something was configured, not that the deployed scheduler reports. Activation is a real run from the intended deployment.</p>
<ol>
<li>
<p><strong>Confirm the key is present in the deployed environment</strong> without printing it:</p>
<pre><code class="language-bash">test -n "$CRONRADAR_API_KEY" &#x26;&#x26; echo "CronRadar key is set" || echo "CronRadar key is missing"
</code></pre>
</li>
<li>
<p><strong>Deploy or restart the scheduler process.</strong> With a framework integration, the recurring jobs appear in the matching application with the scheduler's current cron expressions. For any job on a non-UTC time zone, compare CronRadar's next expected run with the scheduler's next fire time.</p>
</li>
<li>
<p><strong>Let a real scheduled run happen</strong>, or trigger one through the scheduler, then open <strong>Applications</strong>, your application, <strong>Jobs</strong>. Confirm it's the right environment's application and the run shows a terminal result: <code>success</code>, <code>failed</code>, or <code>timeout</code>; a <code>start</code> alone means still in progress. Then confirm the next scheduled run arrives, since a manual trigger doesn't exercise the schedule.</p>
</li>
<li>
<p><strong>Send a channel test</strong> from the application's <strong>Alerts</strong> tab and have the intended recipient confirm it arrived. For Slack, invite CronRadar to the public channel before selecting it. A channel test proves delivery, not that a failed job is detected.</p>
</li>
<li>
<p><strong>Exercise failure only in a separate test application</strong>, with a harmless job and an agreed test destination. Never fail a production job on purpose, and don't let the agent.</p>
</li>
</ol>
<p>Be wary of an agent that "verifies" by sending a hand-made check-in. That proves only that the network path and key work, and the <a href="https://docs.cronradar.com/verify">verification guide</a> warns that doing it with the same key as framework-discovered jobs can hide a discovery or lifecycle wiring problem. An agent with read access can report the check-in timestamps it sees; look at the first one yourself anyway.</p>
<p>When the scheduled job itself calls an LLM or runs an agent, <a href="https://cronradar.com/blog/monitoring-scheduled-ai-agent-runs">monitoring scheduled AI agent runs</a> covers the failures that still exit 0, such as rate limits, truncated output, and refusals. When the scheduled job is the coding agent itself, with <code>claude -p</code> or <code>codex exec</code> running from cron or CI, <a href="https://cronradar.com/blog/scheduled-coding-agent-runs">scheduled coding agent runs</a> covers its permissions, limits, and exit codes.</p>
<h2>Optional: Connect Over MCP</h2>
<p>MCP is not required; the private prompt works over the management REST API. If your client supports remote MCP servers with a configurable bearer header, you can keep the temporary token in its secret configuration instead of the conversation. <a href="https://cronradar.com/blog/mcp-server-scheduled-jobs">The CronRadar MCP server guide</a> covers setup in each client and what its tools can and can't do.</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td><code>cronradar</code></td>
</tr>
<tr>
<td>Transport</td>
<td><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http">Streamable HTTP</a></td>
</tr>
<tr>
<td>Server URL</td>
<td><code>https://api.cronradar.com/api/agent/mcp</code></td>
</tr>
<tr>
<td>Authentication</td>
<td><code>Authorization: Bearer</code> header carrying the temporary token</td>
</tr>
</tbody>
</table>
<p>There is no OAuth sign-in and no automatic token refresh: when the token expires, create fresh access in the console and update the secret. MCP enforces the same workspace permissions and expiry as REST, and its tools mirror the REST operations, starting with <code>get_context</code>.</p>
<p>In Claude Code, <code>.mcp.json</code> <a href="https://code.claude.com/docs/en/mcp">expands environment variables in <code>headers</code></a> (checked October 24, 2026), so the file holds only a variable name:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "cronradar": {
      "type": "http",
      "url": "https://api.cronradar.com/api/agent/mcp",
      "headers": {
        "Authorization": "Bearer ${CRONRADAR_AGENT_TOKEN}"
      }
    }
  }
}
</code></pre>
<p>Set <code>CRONRADAR_AGENT_TOKEN</code> from your password manager when you start the session; in interactive sessions, Claude Code asks for approval before using a server from a project's <code>.mcp.json</code>. Other clients have their own secret mechanisms; use the one their documentation describes rather than writing the token into a config file.</p>
<h3>Treat Job Data as Data</h3>
<p>When an agent reads monitoring data, it reads text your jobs and teammates wrote: job names, descriptions, and failure messages. CronRadar's instructions tell the assistant these are application data, not instructions. That matters because a failure message can carry text from elsewhere, such as an exception quoting an upstream API's response. Instructions planted in content an LLM reads from external sources are what OWASP calls indirect prompt injection in <a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">LLM01:2025</a>, and two of its listed mitigations apply here: mark untrusted content as such, and restrict the model to the minimum privileges it needs. Give investigation sessions read-only access, and keep failure messages free of payloads, logs, and customer data, as the ingestion API already asks.</p>
<h2>Frequently Asked Questions</h2>
<h3>Can Claude Code or Cursor set up cron job monitoring for me?</h3>
<p>Yes. Any coding agent that can edit your repository can add a framework integration or HTTP check-ins, and one with an HTTP or code-execution tool can also use temporary workspace access to inspect existing monitors and read back check-ins. You still review the diff and confirm a real run.</p>
<h3>Is it safe to give an AI agent an API token for monitoring?</h3>
<p>It's a reasonable risk when the token is scoped and short-lived: one workspace, the least permission the task needs, the shortest expiry that fits, and revoked when you finish. Anyone holding the prompt can use it until then, and the assistant provider may keep the transcript. Never give the agent the long-lived deployment key.</p>
<h3>Do I need an MCP server to use CronRadar with an AI agent?</h3>
<p>No. The private setup prompt works over the management REST API with any agent that can make HTTP requests. MCP is an optional alternative for clients that support remote servers with a bearer header.</p>
<h3>What should I check after an AI agent adds monitoring?</h3>
<p>Review the diff for missing timeouts, swallowed exceptions, changed exit codes or retries, schedules that aren't five-field UTC, secrets, and duplicate monitors. Then deploy and confirm that a real scheduled run shows a terminal result in the right application and that a channel test reaches the intended recipient.</p>
<h3>What happens to changes when the agent's access expires?</h3>
<p>Nothing is undone. Applications and jobs the agent created stay, and ingestion keys it created keep working until their own expiry or revocation, so review them in the application's <strong>Setup</strong> tab and revoke any the deployment doesn't use.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>ai agent cron monitoring setup</category>
      <category>claude code cron job monitoring</category>
      <category>cursor add monitoring to scheduled jobs</category>
      <category>ai coding assistant scheduled job monitoring</category>
      <category>ai agent temporary access token monitoring</category>
    </item>
    <item>
      <title><![CDATA[Airflow DAG Not Running on Schedule: Missed Runs, SLAs, Alerts]]></title>
      <link>https://cronradar.com/blog/airflow-schedule-monitoring</link>
      <guid>https://cronradar.com/blog/airflow-schedule-monitoring</guid>
      <description><![CDATA[Why an Airflow DAG misses its schedule, when a daily DAG really runs, what replaced SLAs in Airflow 3, and how to get alerted when a DAG run never starts.]]></description>
      <content:encoded><![CDATA[<p><strong>When an Airflow DAG doesn't run on schedule, check whether it's paused, whether its file imported, whether it has a <code>schedule</code> and a past <code>start_date</code>, whether a concurrency limit is holding the run, and whether the scheduler and DAG processor are running.</strong> New DAGs start paused, an Airflow 3 DAG without a <code>schedule</code> never runs on its own, and a run that can't get a slot waits. None of this raises an error, and Airflow can't alert on a run it never created: SLAs were removed in Airflow 3.0, and their replacement, Deadline Alerts, only watches runs that exist, from inside the scheduler. A check-in from each run to a monitor outside Airflow catches the rest.</p>
<p>This guide covers Apache Airflow 3.3.2, released September 17, 2026 and the current stable release when checked on October 25, 2026, with Airflow 2.11 noted where defaults changed. Source links point to the 3.3.2 tag.</p>
<h2>How Airflow Decides When a Run Starts</h2>
<p>A DAG's <code>schedule</code> accepts a cron expression or preset such as <code>"@daily"</code>, a <code>timedelta</code>, a timetable object, or a list of assets, which trigger the DAG when another task updates them (<a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L342-L345">DAG source</a>). Assets were called datasets in Airflow 2. Airflow 3.0 removed <code>schedule_interval</code> and made <code>None</code> the default <code>schedule</code>, so a DAG without one runs only when triggered (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/release_notes.html#airflow-3-0-0-2025-04-22">3.0.0 release notes</a>).</p>
<h3>Trigger Time and Logical Date</h3>
<p>In Airflow 3, a cron string runs on <code>CronTriggerTimetable</code>: a run's logical date is the moment it's scheduled to start, and its data interval has no length. Airflow 2 used <code>CronDataIntervalTimetable</code>, where each run covers the period between two cron times, starts when that period ends, and is labeled with the period's start. The <code>[scheduler] create_cron_data_intervals</code> setting chooses between them and defaults to <code>False</code> in Airflow 3 and <code>True</code> in 2.x (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-scheduler-create-cron-data-intervals">configuration</a>). Both kinds start runs at the same moment (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#the-time-when-a-dag-run-is-triggered">timetables</a>). For a <code>@daily</code> DAG in UTC:</p>
<table>
<thead>
<tr>
<th></th>
<th>Airflow 3 default</th>
<th>Airflow 2 default</th>
</tr>
</thead>
<tbody>
<tr>
<td>Run starts</td>
<td>2026-10-25 00:00 UTC</td>
<td>2026-10-25 00:00 UTC</td>
</tr>
<tr>
<td><code>logical_date</code></td>
<td>2026-10-25 00:00</td>
<td>2026-10-24 00:00</td>
</tr>
<tr>
<td>Data interval</td>
<td>none (start = end)</td>
<td>2026-10-24 00:00 to 10-25 00:00</td>
</tr>
</tbody>
</table>
<p>A monitor needs only the start time, which is the cron expression itself: <code>0 0 * * *</code>. What changes is the date your tasks see. Templated values such as <code>ds</code> derive from <code>logical_date</code> (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#breaking-changes">upgrade guide</a>), so a task that loads the day in <code>{{ ds }}</code> loads a different day after an upgrade that switches timetables, and still finishes on time and successfully. That's the failure <a href="https://cronradar.com/blog/scheduled-job-silent-success">checks on what a run produced</a> exist for. Switching an existing DAG from a trigger timetable to a data-interval one also skips one scheduled run (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#switching-between-trigger-and-data-interval-timetables-on-an-existing-dag">timetables</a>).</p>
<h3>Catchup, start_date, and max_active_runs</h3>
<p><code>catchup</code> decides what happens to schedule points that pass while a DAG is paused or the scheduler is down. Its default, <code>[scheduler] catchup_by_default</code>, is <code>False</code> in Airflow 3 and was <code>True</code> in 2.x. With it off, the scheduler creates a run only for the latest interval; with <code>catchup=True</code>, it creates one for every interval that hasn't run (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/dag-run.html#catchup">DAG runs</a>). <a href="https://cronradar.com/blog/missed-runs-after-downtime">Missed runs after downtime</a> compares this with other schedulers.</p>
<p>No run is scheduled before <code>start_date</code>. <code>max_active_runs</code> caps the DAG's active runs, 16 by default from <code>[core] max_active_runs_per_dag</code>, and at the cap the scheduler creates no new runs (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-max-active-runs-per-dag">configuration</a>).</p>
<h3>The Components That Have to Be Running</h3>
<p>The scheduler creates DAG runs and hands ready tasks to the executor, which runs inside the scheduler process. The DAG processor parses DAG files into the metadata database, where the scheduler reads them (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/overview.html#required-components">architecture</a>). In Airflow 3 the DAG processor is a separate process started with <code>airflow dag-processor</code>, even in development (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#step-7-changes-to-your-startup-scripts">upgrade guide</a>); in Airflow 2.11 it ran inside the scheduler unless <code>standalone_dag_processor</code> was set (<a href="https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#standalone-dag-processor">2.11 configuration</a>).</p>
<h3>Time Zones</h3>
<p>Airflow stores times in UTC and applies <code>[core] default_timezone</code>, <code>utc</code> unless changed, to naive datetimes. A DAG takes its time zone from a pendulum <code>start_date</code>, and cron schedules follow that zone's daylight saving time: a <code>0 0 * * *</code> DAG in US/Eastern runs at 04:00 UTC in summer and 05:00 UTC in winter. Airflow's docs recommend authoring DAGs in UTC where you can (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timezone.html#cron-schedules">time zones</a>). <a href="https://cronradar.com/blog/cron-daylight-saving-time">Cron daylight saving time</a> covers the transition nights.</p>
<h2>Why an Airflow DAG Didn't Run on Schedule</h2>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Likely cause</th>
<th>Check</th>
</tr>
</thead>
<tbody>
<tr>
<td>DAG listed, no runs, toggle off</td>
<td>Paused: <code>[core] dags_are_paused_at_creation</code> defaults to <code>True</code></td>
<td><code>airflow dags unpause nightly_orders_etl</code></td>
</tr>
<tr>
<td>DAG missing from the UI</td>
<td>Import error, or the DAG processor isn't running</td>
<td><code>airflow dags list-import-errors</code>, <code>airflow jobs check --job-type DagProcessorJob</code></td>
</tr>
<tr>
<td>DAG listed, never scheduled</td>
<td>No <code>schedule</code>, or <code>start_date</code> in the future</td>
<td><code>airflow dags next-execution nightly_orders_etl --field run_after</code></td>
</tr>
<tr>
<td>First run never arrives</td>
<td><code>start_date=datetime.now()</code></td>
<td>Use a fixed <code>start_date</code></td>
</tr>
<tr>
<td>No DAG gets new runs</td>
<td>Scheduler down or stuck</td>
<td><code>airflow jobs check --job-type SchedulerJob</code></td>
</tr>
<tr>
<td>Run created, tasks don't start</td>
<td>Pool full, <code>max_active_tasks</code>, <code>parallelism</code>, or no worker</td>
<td>Task instance details and pool slots in the UI</td>
</tr>
<tr>
<td>Previous run still going, next one missing</td>
<td><code>max_active_runs</code> reached</td>
<td>Runs in the running state</td>
</tr>
<tr>
<td>DAG stopped after repeated failures</td>
<td><code>max_consecutive_failed_dag_runs</code> paused it</td>
<td>Paused state and recent run history</td>
</tr>
<tr>
<td>A run missing after an outage</td>
<td><code>catchup=False</code> created only the latest</td>
<td>Backfill the gap if the data needs it</td>
</tr>
</tbody>
</table>
<p>The pause default is in the <a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-dags-are-paused-at-creation">configuration reference</a>; a DAG can opt out with <code>is_paused_upon_creation=False</code>. The experimental <code>max_consecutive_failed_dag_runs</code> is 0, never, unless someone set it (<a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L375-L381">DAG source</a>). The DAG processor looks for new files every <code>[dag_processor] refresh_interval</code>, 300 seconds by default (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-dag-processor-refresh-interval">configuration</a>), and Airflow's FAQ advises against a <code>datetime.now()</code> start date because the first run keeps moving with it (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/faq.html#what-s-the-deal-with-start-date">FAQ</a>). Tasks without a pool share <code>default_pool</code>, 128 slots, and wait once it's full (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/pools.html">pools</a>); <code>parallelism</code> allows 32 running task instances per scheduler, and <code>max_active_tasks</code>, 16 by default, applies per run since Airflow 3 (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-parallelism">configuration</a>, <a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L375-L376">DAG source</a>).</p>
<h2>SLAs in Airflow 2, Deadline Alerts in Airflow 3</h2>
<p>Airflow 2 had task SLAs: <code>sla=timedelta(...)</code> on a task, misses listed under SLA Misses in the UI and emailed, and an optional DAG-level <code>sla_miss_callback</code>. Tasks over their SLA kept running, and manually triggered runs and event-driven DAGs weren't checked (<a href="https://airflow.apache.org/docs/apache-airflow/2.11.0/core-concepts/tasks.html#slas">Airflow 2.11 SLAs</a>). Airflow 3.0 removed the feature (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#breaking-changes">upgrade guide</a>), but 3.3.2 still accepts <code>sla</code> and <code>sla_miss_callback</code> and only warns that SLAs are gone (<a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/bases/operator.py#L1179-L1183">operator</a>, <a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L707-L714">DAG</a>), so a migrated DAG parses and never alerts.</p>
<p>Deadline Alerts replaced them in Airflow 3.1 and are still marked experimental in the 3.3.2 docs (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/howto/deadline-alerts.html">Deadline Alerts</a>). A <code>DeadlineAlert</code> adds an interval to a reference point (<code>DAGRUN_QUEUED_AT</code>, <code>DAGRUN_LOGICAL_DATE</code>, <code>FIXED_DATETIME</code>, or <code>AVERAGE_RUNTIME</code>) and runs a callback if the DAG run hasn't finished by then. An <code>AsyncCallback</code> runs in the triggerer; a <code>SyncCallback</code>, added in 3.2, is sent to the executor like a task. With the Slack provider installed, on Airflow 3.2 or later:</p>
<pre><code class="language-python">from datetime import timedelta

from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk import DAG, AsyncCallback, DeadlineAlert, DeadlineReference

with DAG(
    dag_id="nightly_orders_deadline",
    schedule="0 2 * * *",
    deadline=DeadlineAlert(
        reference=DeadlineReference.DAGRUN_QUEUED_AT,
        interval=timedelta(hours=1),
        callback=AsyncCallback(
            SlackWebhookNotifier,
            kwargs={"text": "{{ dag_run.dag_id }} missed its deadline at {{ deadline.deadline_time }}"},
        ),
    ),
):
    EmptyOperator(task_id="load_orders")
</code></pre>
<p>On 3.1, import <code>AsyncCallback</code> from <code>airflow.sdk.definitions.deadline</code>. Two limits matter for scheduling problems. First, Airflow computes a deadline when it creates the DAG run, and the scheduler loop checks for passed deadlines (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/howto/sla-to-deadlines.html#two-different-paradigms">migration guide</a>, <a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/serialization/definitions/dag.py#L686-L760">source</a>), so a run that was never created has no deadline and a stopped scheduler checks none. Second, <code>DAGRUN_LOGICAL_DATE</code> counts from the logical date, which on a data-interval timetable is a full period before the run starts; use <code>DAGRUN_QUEUED_AT</code> there.</p>
<h2>Callbacks Only Cover Runs That Exist</h2>
<p><code>on_success_callback</code> and <code>on_failure_callback</code> can be set on the DAG or on tasks; tasks also have retry, execute, and skipped callbacks (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/logging-monitoring/callbacks.html#callback-types">callbacks</a>). Where they run decides what they need:</p>
<ul>
<li><strong>DAG-level callbacks run in the DAG processor.</strong> When a run ends, the scheduler sends it a request; a failed run's <code>reason</code> is <code>task_failure</code>, <code>timed_out</code> for a run past <code>dagrun_timeout</code>, or <code>all_tasks_deadlocked</code> (<a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/dag_processing/processor.py#L365-L428">processor</a>, <a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/dagrun.py#L1225-L1305">DAG run</a>, <a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/jobs/scheduler_job_runner.py#L2853-L2906">scheduler</a>). Their errors go to DAG processor logs, not task logs.</li>
<li><strong>Task-level callbacks run in the task's process on the worker</strong>, after the task finishes (<a href="https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/execution_time/task_runner.py#L2301-L2364">task runner</a>). If that process dies without reporting, the scheduler fails the task and has the DAG processor run its failure callback (<a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/jobs/scheduler_job_runner.py#L3519-L3530">scheduler</a>).</li>
<li><strong>Neither fires for a state you set in the UI or CLI.</strong></li>
</ul>
<h2>Why an External Check Still Helps</h2>
<p>If the scheduler stops, no runs are created, so no task, callback, or deadline runs, and nothing inside Airflow reports it. Airflow's <code>/api/v2/monitor/health</code> endpoint and <code>airflow jobs check</code> show whether the scheduler, DAG processor, and triggerer are heartbeating (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/logging-monitoring/check-health.html">health checks</a>), but something outside has to poll them, and a healthy scheduler says nothing about a paused DAG or one that failed to import. A schedule monitor works from the other side: each run reports, and the monitor, running outside Airflow, alerts when an expected report doesn't arrive, whatever stopped it.</p>
<h2>Report Each DAG Run to CronRadar</h2>
<p>There's no CronRadar SDK for Airflow, the Python SDK isn't installable (checked September 20, 2026), and nothing discovers DAGs automatically, so each DAG reports with its own HTTP check-ins. Airflow's core package already depends on <code>requests</code> (<a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/pyproject.toml#L143">pyproject.toml</a>). This DAG sends <code>start</code> from its first task, <code>complete</code> from its last, and <code>fail</code> from a DAG-level failure callback:</p>
<pre><code class="language-python"># dags/nightly_orders_etl.py
import hashlib
import logging
import os
from datetime import timedelta

import pendulum
import requests
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import dag, task

log = logging.getLogger(__name__)

# The DAG runs in UTC, so Airflow and CronRadar read this expression the same way.
SCHEDULE = "0 2 * * *"
CHECK_IN_URL = "https://cron.life/api/v1/ping/nightly-orders-etl"


def check_in(event, airflow_run_id, message=None):
    """Report one event to CronRadar. Never raises into Airflow."""
    try:
        run_id = airflow_run_id
        if len(run_id) > 128:  # CronRadar's limit; Airflow allows 250
            run_id = hashlib.sha256(run_id.encode()).hexdigest()
        params = {"schedule": SCHEDULE, "gracePeriod": 900, "run_id": run_id}
        if message:
            params["message"] = message[:1000]
        response = requests.post(
            f"{CHECK_IN_URL}/{event}",
            params=params,
            auth=(os.environ["CRONRADAR_API_KEY"], ""),
            timeout=5,
        )
        response.raise_for_status()
    except Exception as error:
        log.warning("CronRadar %s not recorded (%s)", event, type(error).__name__)


def report_failed_run(context):
    """DAG-level failure callback. The DAG processor runs it, not a worker."""
    ti = context.get("ti")
    task_note = f", task {ti.task_id}" if ti is not None else ""
    check_in("fail", context["run_id"], f"Airflow run failed ({context.get('reason')}{task_note})")


@dag(
    schedule=SCHEDULE,
    start_date=pendulum.datetime(2026, 10, 1, tz="UTC"),
    catchup=False,
    max_active_runs=1,
    dagrun_timeout=timedelta(hours=3),
    on_failure_callback=report_failed_run,
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def nightly_orders_etl():
    @task
    def report_start(**context):
        check_in("start", context["run_id"])

    load_orders = BashOperator(
        task_id="load_orders",
        bash_command="python /opt/etl/load_orders.py",
        execution_timeout=timedelta(minutes=45),
    )

    # all_success is the default; it's written out so nobody switches it to all_done.
    @task(trigger_rule="all_success")
    def report_complete(**context):
        check_in("complete", context["run_id"])

    report_start() >> load_orders >> report_complete()


nightly_orders_etl()
</code></pre>
<ul>
<li><strong>The run ID</strong> is the DAG run's own, such as <code>scheduled__2026-10-25T02:00:00+00:00</code>, so <code>start</code> and the terminal event pair up across task retries. Airflow allows 250 characters (<a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/base.py#L59">source</a>) and CronRadar 128, so longer manual run IDs are hashed.</li>
<li><strong>The trigger rule</strong> matters because a leaf task with <code>all_done</code> runs after failures, and if it succeeds the whole run is marked successful (<a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/dag-run.html#dag-run-status">DAG runs</a>).</li>
<li><strong>The key</strong> has to be in the environment of the workers and the DAG processor, which runs the failure callback. Use one CronRadar application and key per Airflow environment. An existing failure callback can stay: pass a list, <code>[notify_team, report_failed_run]</code>.</li>
<li><strong>Timeouts</strong> turn hangs into <code>fail</code> once retries run out. Without them, a hang leaves a <code>start</code> with no finish, which CronRadar flags as stuck only if the next run's expected time plus grace passes with no check-in; with <code>max_active_runs=1</code> the hung run also blocks the next one, so that happens at the next due time.</li>
<li><strong><code>timeout=5</code></strong> limits connecting and each wait for data, not the whole request (<a href="https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts">requests</a>).</li>
</ul>
<table>
<thead>
<tr>
<th>How the run ends</th>
<th>CronRadar receives</th>
<th>Monitor</th>
</tr>
</thead>
<tbody>
<tr>
<td>All tasks succeed</td>
<td><code>start</code>, <code>complete</code></td>
<td><code>healthy</code>, duration recorded</td>
</tr>
<tr>
<td><code>load_orders</code> fails after its retries</td>
<td><code>start</code>, <code>fail</code> with <code>task_failure</code> and the task's ID</td>
<td><code>critical</code> immediately</td>
</tr>
<tr>
<td>Run passes <code>dagrun_timeout</code></td>
<td><code>start</code>, <code>fail</code> with <code>timed_out</code></td>
<td><code>critical</code> immediately</td>
</tr>
<tr>
<td>No run: paused, not parsed, scheduler down, runs at cap</td>
<td>nothing</td>
<td><code>warning</code> at 02:00, <code>critical</code> after grace</td>
</tr>
<tr>
<td>Tasks wait for a slot past the grace period</td>
<td>a late <code>start</code></td>
<td><code>critical</code>, recovers when the run completes</td>
</tr>
<tr>
<td>The run fails and the DAG processor never runs the callback</td>
<td><code>start</code> only</td>
<td>not flagged if the next run starts on time</td>
</tr>
</tbody>
</table>
<p>Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Because every check-in carries the schedule, the first one creates the monitor and a deleted monitor comes back on the next run. But new DAGs start paused, and a monitor created by its first check-in can't flag a DAG that never runs. Create the monitor in the CronRadar dashboard before deploying, with the same key, schedule, and grace: it stays <code>pending</code>, and CronRadar sends a never-executed alert if the first expected run plus grace passes with no report.</p>
<p>A run that loads zero rows still sends <code>complete</code>. To catch that, report counts as bounded outcomes; <a href="https://cronradar.com/blog/etl-job-monitoring">ETL job monitoring</a> shows which numbers to send.</p>
<h2>Choose the Schedule and Grace Period to Send</h2>
<p>Send the time the run should start, in UTC, as a five-field expression (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>):</p>
<table>
<thead>
<tr>
<th>Airflow <code>schedule</code></th>
<th>CronRadar <code>schedule</code></th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"0 2 * * *"</code> in a UTC DAG</td>
<td><code>0 2 * * *</code></td>
<td>Same start time under both timetables</td>
</tr>
<tr>
<td><code>"@daily"</code>, <code>"@hourly"</code></td>
<td><code>0 0 * * *</code>, <code>0 * * * *</code></td>
<td>CronRadar doesn't accept presets</td>
</tr>
<tr>
<td><code>"0 9 * * MON#1"</code></td>
<td><code>0 9 1-7 * 1</code></td>
<td><code>#</code> is a croniter extension; CronRadar requires both day fields to match</td>
</tr>
<tr>
<td><code>"0 9 1 * 1"</code></td>
<td>No single equivalent</td>
<td>Airflow runs on the 1st or any Monday; CronRadar would expect both</td>
</tr>
<tr>
<td><code>"0 6 * * *"</code> with <code>tz="America/New_York"</code></td>
<td><code>0 10 * * *</code>, grace of 3600 plus start delay</td>
<td>06:00 there is 10:00 UTC in summer, 11:00 UTC in winter</td>
</tr>
<tr>
<td><code>timedelta(hours=6)</code></td>
<td>Switch to a cron string</td>
<td>With catchup off, it aligns to when the DAG was enabled, not the clock</td>
</tr>
<tr>
<td>Assets, <code>EventsTimetable</code></td>
<td>None</td>
<td>There's no time at which a run is due</td>
</tr>
</tbody>
</table>
<p>Sources: the <code>#</code> syntax is on Airflow's <a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/cron.html">cron page</a>, Airflow's schedule description reads two restricted day fields as "or" (<a href="https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/timetables/_cron.py#L84-L119">source</a>), and the timedelta alignment is in the <a href="https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#differences-between-the-cron-and-delta-data-interval-timetables">timetable comparison</a>.</p>
<p>An asset-triggered DAG runs whenever an upstream task updates the asset, so no time says a run is due, and a CronRadar monitor needs a cron schedule. It can't be represented. Monitor the time-scheduled DAG that produces the asset, or a scheduled job that checks the data's freshness and sends its own heartbeat, as <a href="https://cronradar.com/blog/etl-job-monitoring">ETL job monitoring</a> describes.</p>
<p>The grace period has to cover the delay before <code>report_start</code> runs, while the scheduler creates the run and a worker picks up the task. Compare recent runs' scheduled times with <code>report_start</code>'s start times in the UI and take the worst case plus a margin. A pod per task or a full pool at 02:00 can take minutes, so CronRadar's 90-second default may be too short; <a href="https://cronradar.com/blog/cron-grace-period">cron grace period</a> works through the arithmetic. <code>schedule</code> and <code>gracePeriod</code> apply only when a check-in creates the monitor; change them later in its settings. Then compare <code>airflow dags next-execution nightly_orders_etl --field run_after -n 3</code> with CronRadar's next expected run, especially for a DAG outside UTC.</p>
<h2>Catch a Stopped Scheduler Sooner</h2>
<p>With one monitor per DAG, a stopped scheduler shows up as each DAG goes overdue, and a daily DAG may not be due for hours. A canary DAG closes the gap: one task on <code>*/10 * * * *</code> that posts a heartbeat to <code>https://cron.life/api/v1/ping/airflow-canary</code>, with no event suffix, <code>schedule=*/10 * * * *</code>, and the same <code>requests</code> options. Give the DAG <code>is_paused_upon_creation=False</code>, <code>catchup=False</code>, and <code>max_active_runs=1</code>. If the scheduler or the workers stop, the canary misses a check-in and goes <code>critical</code> after its grace period, and the per-DAG monitors show which pipelines missed runs.</p>
<h2>Frequently Asked Questions</h2>
<h3>Why is my Airflow DAG not running on schedule?</h3>
<p>Check whether it's paused first: new DAGs start paused because <code>dags_are_paused_at_creation</code> defaults to <code>True</code>. Then run <code>airflow dags list-import-errors</code>, and confirm the DAG has a <code>schedule</code>, which Airflow 3 doesn't set by default, and a fixed <code>start_date</code> in the past. After that, look for a previous run holding <code>max_active_runs</code> or tasks waiting for pool slots. If no DAG is running, check the scheduler and the DAG processor.</p>
<h3>Does Airflow 3 still have SLAs?</h3>
<p>No. Airflow 3.0 removed SLAs, and the <code>sla</code> and <code>sla_miss_callback</code> arguments now only produce a warning. Deadline Alerts, added in 3.1 and still experimental in 3.3, run a callback when a DAG run hasn't finished by a deadline measured from when it was queued, its logical date, a fixed time, or its average runtime. They only cover runs that were created, while the scheduler is running.</p>
<h3>What does catchup do in Airflow?</h3>
<p>It decides whether the scheduler creates runs for schedule points missed while a DAG was paused or the scheduler was down. With <code>catchup=True</code> it creates one run per missed interval; with <code>catchup=False</code>, the default since Airflow 3.0, it creates only the latest, and nothing in Airflow flags the skipped ones.</p>
<h3>When does a daily Airflow DAG actually run?</h3>
<p>At 00:00 in the DAG's time zone. In Airflow 2, and in Airflow 3 with <code>create_cron_data_intervals=True</code>, the run that starts at 00:00 on October 25 has a logical date of October 24, because it covers that day; Airflow 3's default timetable labels it October 25. The schedule a monitor needs is the same either way: <code>0 0 * * *</code> for a DAG in UTC.</p>
<h3>How do I get alerted if the Airflow scheduler stops?</h3>
<p>Use a monitor outside Airflow that notices silence, because a stopped scheduler runs no callbacks or deadlines. Check-ins from each DAG run alert when a run doesn't report, and a canary DAG checking in every ten minutes turns a stopped scheduler into a missed check-in within ten minutes plus its grace period. Airflow's health endpoint and <code>airflow jobs check --job-type SchedulerJob</code> report the scheduler's heartbeat, but need an external poller.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>airflow dag not running on schedule</category>
      <category>airflow sla miss</category>
      <category>airflow monitoring</category>
      <category>airflow dag schedule</category>
      <category>airflow catchup</category>
    </item>
    <item>
      <title><![CDATA[Anacron vs Cron: How /etc/anacrontab Runs Daily Jobs]]></title>
      <link>https://cronradar.com/blog/anacron</link>
      <guid>https://cronradar.com/blog/anacron</guid>
      <description><![CDATA[How anacron runs cron.daily, weekly, and monthly jobs on Fedora, RHEL, Debian, and Ubuntu, what /etc/anacrontab controls, and why cron.daily skipped a day.]]></description>
      <content:encoded><![CDATA[<p><strong>Cron starts a job at an exact minute and skips it if the machine is off at that minute; anacron runs each job once every so many days, whenever the machine is next up, and it is what runs <code>/etc/cron.daily</code>, <code>cron.weekly</code>, and <code>cron.monthly</code> on Fedora and RHEL, and on Debian and Ubuntu when the <code>anacron</code> package is installed.</strong> Anacron isn't a daemon. Cron or a systemd timer starts it, usually once an hour; it compares each job's last-run date with today, runs whatever is due after a delay, and exits. That is why daily scripts run at about 03:06 to 03:51 on Fedora and RHEL, about 07:35 on Debian and Ubuntu, and later on a machine that was off.</p>
<p>Checked October 25, 2026, against two implementations. Cronie 1.7.2, which includes anacron, is what Fedora and Arch ship; RHEL 9 has cronie 1.5.7 and RHEL 10 has 1.7.0 (<a href="https://gitlab.com/redhat/centos-stream/rpms/cronie/-/blob/c9s/cronie.spec">CentOS Stream 9</a>, <a href="https://gitlab.com/redhat/centos-stream/rpms/cronie/-/blob/c10s/cronie.spec">10</a>), with the same anacron code apart from the battery switch. Debian and Ubuntu package anacron 2.3 on its own, even alongside cronie (<a href="https://sources.debian.org/src/cronie/1.7.2-4/debian/rules/">rules</a>): 2.3-43 in Debian 13, 2.3-39 in Ubuntu 24.04, and 2.3-45 in Ubuntu 26.04 (<a href="https://sources.debian.org/src/anacron/">Debian</a>, <a href="https://launchpad.net/ubuntu/+source/anacron">Ubuntu</a>).</p>
<h2>How Anacron Decides a Job Is Due</h2>
<p>Anacron keeps one timestamp file per job in <code>/var/spool/anacron</code>, named after the job and holding a single date such as <code>20261025</code>. When it starts, it subtracts that date from today's. If the difference is at least the job's period in days, the job is due: anacron waits the job's delay, counted in minutes from when anacron itself started, runs the command with <code>/bin/sh -c</code>, and writes the date when the command exits (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/anacron.8">anacron(8)</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/lock.c#L76-L182">lock.c</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/main.c#L379-L398">main.c</a>). Debian's 2.3 code works the same way (<a href="https://sources.debian.org/src/anacron/2.3-43/lock.c/#L77-L152">lock.c</a>).</p>
<p>What follows from that:</p>
<ul>
<li><strong>Daily means once per calendar date, not every 24 hours.</strong> Only the date is stored, so a job that ran at 23:40 is due again just after midnight if anacron starts then. A stored date in the future, after the clock was set back, also makes it due.</li>
<li><strong>A failed run counts as a run.</strong> The date is written whatever the exit status (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/runjob.c#L384-L392">runjob.c</a>), so a script that failed waits for its next period.</li>
<li><strong>Output is mailed, or lost.</strong> Anything a job prints goes to <code>MAILTO</code>, or to the user running anacron, through <code>/usr/sbin/sendmail</code>. Without it, anacron logs that it can't find sendmail and discards the output (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/runjob.c#L211-L228">runjob.c</a>).</li>
</ul>
<h2>The /etc/anacrontab Format</h2>
<p>These are the settings and jobs in Fedora's default file, from cronie 1.7.2 (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/anacrontab">anacrontab</a>):</p>
<pre><code class="language-text">SHELL=/bin/sh
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root
# the maximal random delay added to the base delay of the jobs
RANDOM_DELAY=45
# the jobs will be started during the following hours only
START_HOURS_RANGE=3-22

#period in days   delay in minutes   job-identifier   command
1	5	cron.daily		nice run-parts /etc/cron.daily
7	25	cron.weekly		nice run-parts /etc/cron.weekly
@monthly 45	cron.monthly		nice run-parts /etc/cron.monthly
</code></pre>
<p>Debian 13's default has the same three jobs with delays of 5, 10, and 15 minutes, adds <code>@yearly 20 cron.yearly</code>, sets <code>HOME</code> and <code>LOGNAME</code>, and has neither scheduling variable (<a href="https://sources.debian.org/src/anacron/2.3-43/debian/anacrontab/">anacrontab</a>). Ubuntu's leaves out the <code>PATH</code> line, and 24.04's has no yearly job.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>Period</td>
<td>Days between runs, such as <code>1</code> or <code>7</code>, or a name such as <code>@monthly</code></td>
</tr>
<tr>
<td>Delay</td>
<td>Minutes after anacron starts before this job starts; <code>0</code> for none</td>
</tr>
<tr>
<td>Job identifier</td>
<td>Any non-blank characters except <code>/</code>; names the timestamp file and appears in the logs</td>
</tr>
<tr>
<td>Command</td>
<td>The rest of the line, run with <code>/bin/sh -c</code></td>
</tr>
</tbody>
</table>
<p>Which period names work depends on the version:</p>
<table>
<thead>
<tr>
<th>anacron</th>
<th>Period names accepted</th>
</tr>
</thead>
<tbody>
<tr>
<td>cronie 1.7.2, 1.7.0, 1.5.7 (Fedora, Arch, RHEL)</td>
<td><code>@daily</code>, <code>@weekly</code>, <code>@monthly</code>; also <code>@yearly</code> and <code>@annually</code>, undocumented</td>
</tr>
<tr>
<td>Debian 2.3-43 (Debian 13), 2.3-39 (Ubuntu 24.04)</td>
<td><code>@monthly</code>, <code>@yearly</code></td>
</tr>
<tr>
<td>Debian 2.3-45 and later (Ubuntu 26.04)</td>
<td>Adds <code>@daily</code> and <code>@weekly</code></td>
</tr>
</tbody>
</table>
<p>That comes from cronie's <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/readtab.c#L234-L245">readtab.c</a>, Debian's <a href="https://sources.debian.org/src/anacron/2.3-43/readtab.c/#L207-L215">readtab.c</a>, and Debian's <a href="https://sources.debian.org/src/anacron/2.3-50/debian/changelog/">changelog</a> for 2.3-44. <code>@monthly</code> runs once a month whatever its length; write daily and weekly periods as <code>1</code> and <code>7</code>, which every version reads the same way.</p>
<h3>Environment Lines and the Two Scheduling Variables</h3>
<p>A <code>NAME=value</code> line sets an environment variable for every job below it. <code>SHELL</code> is only passed along: both implementations run commands with <code>/bin/sh -c</code> whatever it says (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/runjob.c#L184">cronie</a>, <a href="https://sources.debian.org/src/anacron/2.3-43/runjob.c/#L125">Debian</a>). <code>MAILTO</code> picks the mail recipient; on cronie, an empty one turns mail off. Cronie's anacron also reads two lines as settings:</p>
<ul>
<li><strong><code>RANDOM_DELAY=45</code>:</strong> each time anacron starts, it picks one random number from 0 to 45 and adds it to the delay of every job listed after that line (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/readtab.c#L328-L335">readtab.c</a>).</li>
<li><strong><code>START_HOURS_RANGE=3-22</code>:</strong> a due job is skipped unless its start time, anacron's start plus the delay, falls between 03:00 and 21:59; a missed window means no run that day (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/lock.c#L156-L172">lock.c</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/anacrontab.5">anacrontab(5)</a>).</li>
</ul>
<p>Debian's anacron 2.3 has neither. It passes both lines to jobs as ordinary environment variables, so a <code>START_HOURS_RANGE</code> copied from a Fedora guide does nothing, and <code>anacron -T</code> won't flag it.</p>
<p>One gap in cronie 1.7.2: the hours check runs only when the job's timestamp file already holds a date. A new job, or a fresh install with empty timestamp files, runs at the next hourly start at any hour. The fix was merged in October 2026 and isn't in a release yet (<a href="https://github.com/cronie-crond/cronie/pull/248">#248</a>).</p>
<h2>When /etc/cron.daily Runs</h2>
<p>Times are the host's local time. Without anacron, cron runs the directories only if the machine is up at that minute, and a missed day is gone; <a href="https://cronradar.com/blog/missed-runs-after-downtime">missed runs after downtime</a> compares what other schedulers do.</p>
<table>
<thead>
<tr>
<th>System</th>
<th>cron.daily, host up all night</th>
<th>Host that was off</th>
</tr>
</thead>
<tbody>
<tr>
<td>Fedora, RHEL, Arch</td>
<td>03:06 to 03:51</td>
<td>First :01 after boot plus 5 to 50 minutes, if before 22:00</td>
</tr>
<tr>
<td>Fedora, RHEL with <code>cronie-noanacron</code></td>
<td>04:02</td>
<td>Skipped that day</td>
</tr>
<tr>
<td>Debian 13, Ubuntu 24.04 and 26.04 with anacron</td>
<td>07:35 to 07:40</td>
<td>5 minutes after boot, at any hour</td>
</tr>
<tr>
<td>Debian, Ubuntu without anacron</td>
<td>06:25</td>
<td>Skipped that day</td>
</tr>
</tbody>
</table>
<h3>Fedora, RHEL, and Arch</h3>
<p>Crond runs <code>run-parts /etc/cron.hourly</code> at one minute past every hour (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/0hourly">0hourly</a>). The first script there, <code>0anacron</code>, exits at once if <code>/var/spool/anacron/cron.daily</code> already holds today's date or the machine is on battery, and otherwise starts <code>anacron -s</code> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/0anacron">0anacron</a>). With the defaults, the 03:01 start is the first that can put cron.daily inside <code>3-22</code>, hence 03:06 to 03:51. When the weekly and monthly jobs are due, they follow in the same run, one at a time because of <code>-s</code>.</p>
<figure class="not-prose my-8">
<div class="overflow-x-auto">
<svg viewBox="0 0 720 240" role="img" aria-label="Crond runs cron.hourly at 03:01, its 0anacron script starts anacron -s, and anacron waits 5 minutes plus a random 0 to 45 minutes, so cron.daily starts between 03:06 and 03:51, inside START_HOURS_RANGE 3-22." class="h-auto w-full min-w-[560px]" font-family="inherit" font-size="13">
<defs>
<marker id="anacron-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" class="fill-foreground"/></marker>
</defs>
<rect x="35" y="12" width="170" height="72" rx="6" class="fill-muted stroke-border" stroke-width="1.5"/>
<rect x="275" y="12" width="170" height="72" rx="6" class="fill-muted stroke-border" stroke-width="1.5"/>
<rect x="515" y="12" width="170" height="72" rx="6" class="fill-muted stroke-border" stroke-width="1.5"/>
<text x="120" y="34" text-anchor="middle" font-weight="600" class="fill-foreground">crond</text>
<text x="120" y="54" text-anchor="middle" font-size="12" class="fill-muted-foreground">runs /etc/cron.hourly</text>
<text x="120" y="70" text-anchor="middle" font-size="12" class="fill-muted-foreground">at :01 every hour</text>
<text x="360" y="34" text-anchor="middle" font-weight="600" class="fill-foreground">0anacron</text>
<text x="360" y="54" text-anchor="middle" font-size="12" class="fill-muted-foreground">exits if today's date</text>
<text x="360" y="70" text-anchor="middle" font-size="12" class="fill-muted-foreground">is stored, or on battery</text>
<text x="600" y="34" text-anchor="middle" font-weight="600" class="fill-foreground">anacron</text>
<text x="600" y="54" text-anchor="middle" font-size="12" class="fill-muted-foreground">checks the timestamp,</text>
<text x="600" y="70" text-anchor="middle" font-size="12" class="fill-muted-foreground">waits 5 min + 0-45 min</text>
<line x1="207" y1="48" x2="273" y2="48" class="stroke-foreground" stroke-width="1.5" marker-end="url(#anacron-arrow)"/>
<line x1="447" y1="48" x2="513" y2="48" class="stroke-foreground" stroke-width="1.5" marker-end="url(#anacron-arrow)"/>
<text x="240" y="40" text-anchor="middle" font-size="12" class="fill-foreground">03:01</text>
<text x="480" y="40" text-anchor="middle" font-size="12" class="fill-foreground">anacron -s</text>
<path d="M720,104 H40 V128 H720" class="fill-muted stroke-border" stroke-width="1"/>
<text x="48" y="120" font-size="12" class="fill-foreground">START_HOURS_RANGE=3-22: jobs start only between 03:00 and 21:59</text>
<rect x="51" y="144" width="55" height="28" class="fill-background stroke-border" stroke-width="1"/>
<text x="78.5" y="162" text-anchor="middle" font-size="12" class="fill-foreground">5 min</text>
<rect x="106" y="144" width="495" height="28" class="fill-success stroke-success" fill-opacity="0.15" stroke-width="1.5"/>
<text x="353.5" y="162" text-anchor="middle" font-size="12" class="fill-success-ink">+ random 0 to 45 min: cron.daily starts in this window</text>
<line x1="40" y1="192" x2="712" y2="192" class="stroke-muted-foreground" stroke-width="1"/>
<line x1="51" y1="172" x2="51" y2="196" class="stroke-muted-foreground" stroke-width="1"/>
<line x1="106" y1="172" x2="106" y2="196" class="stroke-muted-foreground" stroke-width="1"/>
<line x1="601" y1="172" x2="601" y2="196" class="stroke-muted-foreground" stroke-width="1"/>
<text x="51" y="212" text-anchor="middle" font-size="12" class="fill-foreground">03:01</text>
<text x="106" y="212" text-anchor="middle" font-size="12" class="fill-foreground">03:06</text>
<text x="601" y="212" text-anchor="middle" font-size="12" class="fill-foreground">03:51</text>
<text x="51" y="228" text-anchor="middle" font-size="12" class="fill-muted-foreground">anacron starts</text>
</svg>
</div>
<figcaption class="mt-3 text-sm leading-relaxed text-muted-foreground">With the defaults on a host that is up all night, the 03:01 run of cron.hourly is the first that can put cron.daily inside START_HOURS_RANGE=3-22, so cron.daily starts 5 minutes plus a random 0 to 45 minutes later, between 03:06 and 03:51.</figcaption>
</figure>
<p>Because of that early exit, anacron isn't started again once cron.daily has run that day, so a job you add to <code>/etc/anacrontab</code> later in the day is first considered the next morning.</p>
<p>Fedora's cronie requires a package that provides <code>dailyjobs</code>: <code>cronie-anacron</code>, or <code>cronie-noanacron</code>, which runs the directories from fixed cron lines at 04:02 daily, 04:22 on Sundays, and 04:42 on the 1st (<a href="https://src.fedoraproject.org/rpms/cronie/blob/rawhide/f/cronie.spec">cronie.spec</a>, <a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/dailyjobs">dailyjobs</a>).</p>
<h3>Debian and Ubuntu</h3>
<p><code>/etc/crontab</code> still runs the directories at 06:25 daily, 06:47 on Sundays, and 06:52 on the 1st, but each line begins with <code>test -x /usr/sbin/anacron ||</code> and does nothing once anacron is installed (<a href="https://sources.debian.org/src/cron/3.0pl1-197/debian/crontab.main/">crontab</a>); Ubuntu 24.04's is identical. On systemd hosts, <code>anacron.timer</code> fires on the half hour from 07:30 to 23:30, up to five minutes late at random, with <code>Persistent=true</code> (<a href="https://sources.debian.org/src/anacron/2.3-43/debian/anacron.timer/">anacron.timer</a>), and <code>anacron.service</code> also starts at boot, running <code>anacron -d -q -s</code> (<a href="https://sources.debian.org/src/anacron/2.3-43/debian/anacron.service/">anacron.service</a>, <a href="https://sources.debian.org/src/anacron/2.3-43/debian/default/">default</a>). With no hours range, a host booted at 02:00 runs cron.daily at 02:05. Without systemd, <code>/etc/cron.d/anacron</code> starts anacron at the same :30 times (<a href="https://sources.debian.org/src/anacron/2.3-43/debian/cron.d/">cron.d</a>).</p>
<p>Debian's <code>/etc/cron.daily/0anacron</code> runs <code>anacron -u cron.daily</code>, so running the directory by hand with run-parts also updates the timestamp (<a href="https://sources.debian.org/src/anacron/2.3-43/debian/anacron.0anacron.cron.daily/">0anacron</a>).</p>
<h2>Why cron.daily Didn't Run Today</h2>
<ul>
<li><strong>Anacron thinks it already ran.</strong> Today's date in <code>/var/spool/anacron/cron.daily</code> ends the question, even if that run failed or the date came from <code>anacron -u</code>.</li>
<li><strong>The machine wasn't up in the window.</strong> With cronie's defaults, a host that comes up after about 21:00 waits until after 03:00 tomorrow, and a host that is only on between 22:00 and 03:00 never runs it.</li>
<li><strong>It's on battery.</strong> Debian's service has <code>ConditionACPower=true</code>; a skipped start logs a line ending <code>was skipped because of an unmet condition check (ConditionACPower=true)</code> (<a href="https://github.com/systemd/systemd/blob/v257/src/core/job.c#L800-L813">job.c</a>), and <a href="https://sources.debian.org/src/anacron/2.3-43/debian/README.debian/">README.Debian</a> shows the drop-in that removes the check. Cronie's <code>0anacron</code> exits when every mains supply in <code>/sys/class/power_supply</code> is offline; on Fedora and RHEL 10, <code>ANACRON_RUN_ON_BATTERY_POWER=yes</code> in <code>/etc/default/anacron</code> overrides it, and RHEL 9's 1.5.7 has no override (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.5.7/contrib/0anacron">1.5.7</a>).</li>
<li><strong>Nothing started anacron.</strong> On cronie, crond has to be running (<code>crond.service</code>; on Arch, <code>cronie.service</code>), and <code>0anacron</code> listed in <code>/etc/cron.hourly/jobs.deny</code> disables it (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/anacrontab.5">anacrontab(5)</a>). On Debian, <code>systemctl list-timers anacron.timer</code> should show a next run.</li>
<li><strong>Run-parts skipped the script.</strong> Both run-parts versions skip files that aren't executable. Debian's runs only names made of letters, digits, underscores, and hyphens, so <code>backup.sh</code> never runs (<a href="https://manpages.debian.org/trixie/debianutils/run-parts.8.en.html">run-parts(8)</a>). Fedora's allows dots, but skips names ending in <code>~</code>, <code>.rpmnew</code>, <code>.rpmsave</code>, and similar, and any name listed in that directory's <code>jobs.deny</code> (<a href="https://github.com/cronie-crond/crontabs/blob/crontabs-20190603/run-parts">run-parts</a>).</li>
<li><strong>The script exits on purpose.</strong> Debian 13's <code>/etc/cron.daily/logrotate</code> exits on systemd hosts, where <code>logrotate.timer</code> rotates logs instead (<a href="https://salsa.debian.org/debian/logrotate/-/blob/debian/3.22.0-1/debian/patches/debian/skip-cronjob-when-running-with-systemd.patch">patch</a>); <code>systemctl list-timers</code> shows work that moved to timers.</li>
<li><strong>Yesterday's run is still going.</strong> Anacron locks a job while it runs, and cronie's next start logs <code>locked by another anacron - skipping</code>. On Debian the service is still active, and a timer doesn't start a unit that is already running (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html">systemd.timer(5)</a>).</li>
</ul>
<p>These commands answer most of those questions:</p>
<pre><code class="language-bash"># Last-run date per job (YYYYMMDD), and when each file was last written
ls -l --time-style=long-iso /var/spool/anacron
sudo head /var/spool/anacron/*

# Anacron's log lines, on either family
sudo journalctl -t anacron --since yesterday

# Debian and Ubuntu: the timer's next run, and starts skipped on battery
systemctl list-timers anacron.timer
sudo journalctl -u anacron.service --since yesterday

# What run-parts would run, without running it
run-parts --test /etc/cron.daily
</code></pre>
<p>Anacron logs to syslog with facility <code>cron</code> (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/anacron.8">anacron(8)</a>). The lines to look for, with example values:</p>
<pre><code class="language-text">Anacron started on 2026-10-25
Will run job `cron.daily' in 17 min.
Job `cron.daily' started
Job `cron.daily' terminated (exit status: 1)
Job `cron.daily' locked by another anacron - skipping
Normal exit (0 jobs run)
</code></pre>
<p>Debian's first line reads <code>Anacron 2.3 started on</code>, and the minutes in <code>Will run job</code> include any random delay. <code>Normal exit (0 jobs run)</code> means nothing was due or, on cronie, that a due job's start time fell outside <code>START_HOURS_RANGE</code>, a skip anacron doesn't log separately (<a href="https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/anacron/lock.c#L165-L172">lock.c</a>). No anacron lines at all after the daily run is normal on cronie, since <code>0anacron</code> stops starting it. If the daily job ran, the problem is in a script; <a href="https://cronradar.com/blog/cron-job-not-running">cron job not running</a> covers paths, environment, and permissions.</p>
<h2>Running Anacron by Hand</h2>
<pre><code class="language-bash"># Check /etc/anacrontab; exit status 0 means no errors
sudo anacron -T

# Run cron.daily now, ignoring its timestamp and delay, in the foreground
sudo anacron -d -f -n cron.daily

# Record cron.daily as done today without running it
sudo anacron -u cron.daily
</code></pre>
<p>The options are the same in both: <code>-f</code> ignores timestamps, <code>-n</code> ignores delays and implies <code>-s</code>, <code>-s</code> runs jobs one at a time, <code>-d</code> stays in the foreground and prints anacron's messages, <code>-q</code> hides them, <code>-u</code> updates timestamps without running anything, and <code>-T</code> checks the file, printing a line such as <code>anacron: Invalid syntax in /etc/anacrontab on line 14 - skipping this line</code>. Arguments are job identifiers or quoted shell patterns such as <code>'cron.*'</code>. <code>-t</code> and <code>-S</code> pick another anacrontab and spool directory, which is how an ordinary user runs a personal anacron. On cronie, <code>-f</code> and <code>-n</code> also bypass <code>START_HOURS_RANGE</code>. Job output is still mailed rather than printed, so run a script directly to watch it.</p>
<h2>Changing When Daily Jobs Run</h2>
<p>On cronie, edit the variables. <code>START_HOURS_RANGE=1-5</code> with <code>RANDOM_DELAY=0</code> runs cron.daily at 01:06 on a host that is up, or at the first start after boot before 05:00.</p>
<p>On Debian and Ubuntu, override the timer with <code>sudo systemctl edit anacron.timer</code>:</p>
<pre><code class="language-ini"># /etc/systemd/system/anacron.timer.d/override.conf
[Timer]
OnCalendar=
OnCalendar=*-*-* 01..05:30
</code></pre>
<p>The empty <code>OnCalendar=</code> clears the packaged schedule before the new one is added (<a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html">systemd.timer(5)</a>). The start at boot remains, because <code>multi-user.target</code> starts <code>anacron.service</code> directly.</p>
<h2>Anacron vs Cron vs systemd Timers</h2>
<table>
<thead>
<tr>
<th>Behavior</th>
<th>cron</th>
<th>anacron</th>
<th>systemd timer with <code>Persistent=true</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Shortest interval</td>
<td>One minute</td>
<td>One day</td>
<td>Seconds</td>
</tr>
<tr>
<td>Start time</td>
<td>The scheduled minute</td>
<td>A window: <code>START_HOURS_RANGE</code> (cronie), the timer (Debian)</td>
<td>The <code>OnCalendar=</code> time, plus any random delay</td>
</tr>
<tr>
<td>Run missed while off</td>
<td>Skipped</td>
<td>Once, at the next anacron start</td>
<td>Once, when the timer next starts</td>
</tr>
<tr>
<td>What it records</td>
<td>Nothing</td>
<td>The date of each job's last run</td>
<td>The time of the last trigger</td>
</tr>
<tr>
<td>Where results go</td>
<td>Syslog, and mail when there is output</td>
<td>Syslog, and mail when there is output</td>
<td>The journal, with each run's exit status</td>
</tr>
</tbody>
</table>
<p>Anacron suits work that should happen once a day or less on machines that aren't always on, when the exact hour doesn't matter. A timer can do the same catch-up at a time you choose, with logs in the journal (<a href="https://cronradar.com/blog/systemd-timers">systemd timers</a>); <a href="https://cronradar.com/blog/systemd-timers-vs-cron">systemd timers vs cron</a> covers moving a job over. <a href="https://cronradar.com/blog/crontab-guide">The crontab guide</a> has a script that lists every schedule on a host, anacron's included.</p>
<h2>Monitoring an Anacron Job</h2>
<p>Anacron logs a run and mails its output, but nothing reports a day it didn't run. A check-in monitor does: the job reports to CronRadar when it starts and finishes, and CronRadar alerts when an expected report doesn't arrive. There is no CronRadar SDK for anacron and no auto-discovery, so each job reports with its own HTTP check-in.</p>
<p>CronRadar has no "once a day, any time" setting: a monitor expects each run at a five-field cron time, evaluated in UTC, plus a grace period, and every check-in moves the expectation to the next scheduled time. Set the schedule to the earliest time the job can start and make the grace period reach past the latest. A run earlier than the schedule breaks this, because its check-in only moves the expectation to that same day's time, which then passes with nothing arriving.</p>
<p>Take a job added to cronie's default anacrontab with a 15-minute delay, on a server that stays up and keeps its clock in UTC:</p>
<pre><code class="language-text">1	15	daily-backup	/usr/local/bin/daily-backup-monitored.sh
</code></pre>
<ul>
<li><strong>Earliest start: 03:00.</strong> <code>START_HOURS_RANGE=3-22</code> keeps any job from starting earlier.</li>
<li><strong>Latest start: about 04:01,</strong> the 03:01 run plus 15 minutes plus the full 45-minute random delay, later if cron.daily is still running, because <code>-s</code> makes jobs wait their turn.</li>
<li><strong>So <code>schedule=0 3 * * *</code> with <code>gracePeriod=7200</code></strong> puts the deadline at 05:00, which covers a cron.daily run of up to about an hour. The monitor shows <code>warning</code> each morning from 03:00 until the start arrives; only <code>critical</code>, past the grace period, opens an incident.</li>
</ul>
<p>On a laptop or a VM that is shut down overnight, the job can start any time until about 22:00, so the same schedule needs <code>gracePeriod=72000</code> (20 hours), and a day the machine stays off alerts at 23:00. On Debian and Ubuntu, the start at boot can come at any hour, so use local midnight as the schedule with a grace period under 24 hours, or move a job that needs a predictable time to a cron line or a timer. If the host's clock isn't on UTC, convert, and with daylight saving time use the earlier UTC time and an extra hour of grace (<a href="https://docs.cronradar.com/schedules">schedule syntax</a>).</p>
<p>The wrapper reports the start, runs the job, and reports complete or fail with the job's own exit code. Each report gives up after five seconds and ignores errors:</p>
<pre><code class="language-bash">#!/bin/sh
# /usr/local/bin/daily-backup-monitored.sh, started by anacron as daily-backup.
# /etc/anacrontab is world-readable, so the key lives in a root-only file.
CRONRADAR_API_KEY=$(cat /etc/cronradar/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/daily-backup"
run_id="daily-backup-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=0 3 * * *" \
    --data-urlencode "gracePeriod=7200" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
/usr/local/bin/daily-backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
else
  report fail --data-urlencode "message=daily-backup.sh exited with code $rc"
fi
exit "$rc"
</code></pre>
<p>After adding the line, run <code>sudo anacron -u daily-backup</code> once. Cronie 1.7.2 skips the hours check for a job with an empty timestamp file, so the first run could land just after midnight, and its check-in would move the expectation only to 03:00 that day. With today's date recorded, the first run waits for the next morning's window.</p>
<p>The first check-in creates the monitor. <code>schedule</code> and <code>gracePeriod</code> apply only then; change them later in the monitor's settings. A <code>fail</code> alerts immediately. A start with no finish shows as stuck only once the next day's deadline passes, so give the backup its own time limit, for example with <code>timeout</code>, to turn a hang into a failure.</p>
<h2>Frequently Asked Questions</h2>
<h3>What is the difference between cron and anacron?</h3>
<p>Cron is a daemon that starts each job at the minute its schedule names, and only if the machine is up then. Anacron is a program that cron or a systemd timer starts; it runs any job whose period in days has passed since the date in its timestamp file, then exits. Cron handles exact times and intervals down to a minute; anacron handles daily, weekly, and monthly work on machines that aren't always on.</p>
<h3>When does /etc/cron.daily run?</h3>
<p>On a host that stays up, at about 03:06 to 03:51 local time on Fedora, RHEL, and Arch, and 07:35 to 07:40 on Debian and Ubuntu with anacron. After downtime it runs soon after boot, inside cronie's 03:00 to 22:00 window or at any hour on Debian. Without anacron, cron runs it at a fixed time and skips it if the machine is off.</p>
<h3>Why didn't cron.daily run today?</h3>
<p>Check <code>/var/spool/anacron/cron.daily</code> first: today's date there means anacron counts the day as done, even after a failed run. Otherwise, look for a machine that was off or on battery during the window, a stopped crond or disabled <code>anacron.timer</code>, or a script run-parts skips for its name or a missing execute bit.</p>
<h3>Can anacron run jobs more than once a day?</h3>
<p>No. Periods are whole days and the timestamp file holds only a date, so the most often a job can run is once per calendar date. For hourly or more frequent work, use a cron line or a systemd timer.</p>
<h3>How do I force anacron to run now?</h3>
<p>Run <code>sudo anacron -d -f -n cron.daily</code> to run that job at once, in the foreground, ignoring its timestamp and delay; leave out the job name to run every job. On Debian and Ubuntu, <code>sudo systemctl start anacron.service</code> runs only the jobs that are due, with their delays, and does nothing on battery.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>anacron</category>
      <category>anacron vs cron</category>
      <category>anacrontab</category>
      <category>cron.daily not running</category>
      <category>when does cron.daily run</category>
    </item>
    <item>
      <title><![CDATA[EventBridge Scheduler Cron: Expressions, Retries, and Monitoring]]></title>
      <link>https://cronradar.com/blog/aws-eventbridge-scheduler</link>
      <guid>https://cronradar.com/blog/aws-eventbridge-scheduler</guid>
      <description><![CDATA[How EventBridge Scheduler cron and rate expressions work, how retries and dead-letter queues handle failures, and how to get alerted when a job never runs.]]></description>
      <content:encoded><![CDATA[<p><strong>An EventBridge Scheduler cron expression has six fields, <code>cron(minutes hours day-of-month month day-of-week year)</code>, and one of the two day fields must be <code>?</code>: <code>cron(30 2 * * ? *)</code> runs at 02:30 every day.</strong> Day-of-week runs from 1 (Sunday) to 7 (Saturday), the expression is evaluated in UTC unless the schedule names a time zone, and nothing runs more often than once a minute. Scheduler retries a failed call to its target, but a job that fails after Lambda accepts the event is outside its view, and a disabled schedule raises no error at all. <a href="https://cronradar.com/blog/cron-expression">Cron expression syntax</a> covers the standard five-field format that AWS extends.</p>
<h2>EventBridge Scheduler vs. EventBridge Scheduled Rules</h2>
<p>EventBridge has two ways to run something on a schedule, and both accept the same <code>cron()</code> and <code>rate()</code> syntax. Scheduled rules are, in AWS's words, "a legacy feature of EventBridge", and "We recommend that you use Scheduler to invoke targets on a schedule" (<a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html">scheduled rules</a>). Features and default quotas, checked October 25, 2026:</p>
<table>
<thead>
<tr>
<th></th>
<th>EventBridge Scheduler</th>
<th>Scheduled rule (legacy)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Time zone</td>
<td>Any IANA zone in <code>ScheduleExpressionTimezone</code>; UTC by default</td>
<td>UTC only</td>
</tr>
<tr>
<td>One-time runs</td>
<td><code>at()</code> expressions</td>
<td>No <code>at()</code></td>
</tr>
<tr>
<td>Flexible time window</td>
<td>1 to 1,440 minutes</td>
<td>None</td>
</tr>
<tr>
<td>Targets</td>
<td>One per schedule: templated targets, or a universal target for "more than 270 AWS services and over 6,000 API operations"</td>
<td>Up to 5 per rule</td>
</tr>
<tr>
<td>Permissions</td>
<td>An execution role, always</td>
<td>An execution role, or a resource-based policy for Lambda, SNS, and SQS</td>
</tr>
<tr>
<td>Retries and DLQ</td>
<td>Retry policy and SQS dead-letter queue per schedule</td>
<td>Retry policy (defaults: 24 hours, 185 retries) and SQS dead-letter queue</td>
</tr>
<tr>
<td>Default quota</td>
<td>10,000,000 schedules per Region</td>
<td>300 rules per event bus in most Regions; scheduled rules go only on the default bus</td>
</tr>
</tbody>
</table>
<p>Sources: Scheduler <a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html">features</a>, <a href="https://docs.aws.amazon.com/scheduler/latest/APIReference/API_Target.html">targets</a>, and <a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/scheduler-quotas.html">quotas</a>; rule <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-rule-schedule.html">schedules</a>, <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-use-resource-based.html">permissions</a>, <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rule-retry-policy.html">retries</a>, and <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-quota.html">quotas</a>. The rest of this guide covers Scheduler.</p>
<h2>The cron() Expression Format</h2>
<table>
<thead>
<tr>
<th>Field</th>
<th>Values</th>
<th>Wildcards</th>
</tr>
</thead>
<tbody>
<tr>
<td>Minutes</td>
<td>0-59</td>
<td><code>, - * /</code></td>
</tr>
<tr>
<td>Hours</td>
<td>0-23</td>
<td><code>, - * /</code></td>
</tr>
<tr>
<td>Day-of-month</td>
<td>1-31</td>
<td><code>, - * ? / L W</code></td>
</tr>
<tr>
<td>Month</td>
<td>1-12 or JAN-DEC</td>
<td><code>, - * /</code></td>
</tr>
<tr>
<td>Day-of-week</td>
<td>1-7 or SUN-SAT</td>
<td><code>, - * ? L #</code></td>
</tr>
<tr>
<td>Year</td>
<td>1970-2199</td>
<td><code>, - * /</code></td>
</tr>
</tbody>
</table>
<p>Three rules trip up anyone coming from crontab (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html">schedule types</a>):</p>
<ul>
<li><strong>One day field must be <code>?</code>.</strong> You can't restrict both day fields or use <code>*</code> in both: "If you use it in one, you must use ? in the other."</li>
<li><strong>Sunday is 1.</strong> <code>2-6</code> is Monday through Friday, so a crontab's <code>1-5</code> pasted in becomes Sunday through Thursday. Names such as <code>MON-FRI</code> mean the same everywhere.</li>
<li><strong>The year is required.</strong> Use <code>*</code> for every year.</li>
</ul>
<p>In day-of-month, <code>L</code> is the last day and <code>15W</code> the weekday nearest the 15th. In day-of-week, <code>6L</code> is the last Friday and <code>2#1</code> the first Monday. Expressions that would fire more than once a minute aren't supported (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/troubleshooting.html#troubleshooting-patterns">troubleshooting</a>), and a run lands anywhere in its minute: a schedule for <code>1:00</code> "will invoke the target API between <code>1:00:00</code> and <code>1:00:59</code>."</p>
<table>
<thead>
<tr>
<th>Schedule</th>
<th>Expression</th>
</tr>
</thead>
<tbody>
<tr>
<td>Every day at 02:30</td>
<td><code>cron(30 2 * * ? *)</code></td>
</tr>
<tr>
<td>Every 15 minutes</td>
<td><code>cron(0/15 * * * ? *)</code></td>
</tr>
<tr>
<td>Weekdays at 09:00</td>
<td><code>cron(0 9 ? * MON-FRI *)</code></td>
</tr>
<tr>
<td>Last day of the month at 23:00</td>
<td><code>cron(0 23 L * ? *)</code></td>
</tr>
<tr>
<td>Last Friday of the month at 10:15</td>
<td><code>cron(15 10 ? * 6L *)</code></td>
</tr>
<tr>
<td>First Monday of the month at 09:00</td>
<td><code>cron(0 9 ? * 2#1 *)</code></td>
</tr>
<tr>
<td>Daily at 12:00, during 2027 only</td>
<td><code>cron(0 12 * * ? 2027)</code></td>
</tr>
</tbody>
</table>
<h3>rate() and at() Expressions</h3>
<p><code>rate(value unit)</code> takes minutes, hours, or days, as in <code>rate(5 minutes)</code>. The schedule's <code>StartDate</code> sets the first occurrence; without one, it "starts invoking the target immediately." The interval is fixed, but the clock times depend on when it started. <code>rate(1 days)</code> is 24 hours of real time, so its local time moves an hour at each daylight saving change.</p>
<p><code>at(2026-11-20T13:00:00)</code> runs once. A completed one-time schedule still counts against the quota, so set <code>ActionAfterCompletion</code> to <code>DELETE</code> (<a href="https://docs.aws.amazon.com/scheduler/latest/APIReference/API_CreateSchedule.html">CreateSchedule</a>).</p>
<h3>Time Zones and Daylight Saving Time</h3>
<p>Set <code>ScheduleExpressionTimezone</code> to an IANA name such as <code>America/New_York</code> for <code>cron()</code> and <code>at()</code> schedules; without it they run in UTC. Scheduler follows the zone's daylight saving changes: an invocation in the hour the spring change skips "is skipped", and one in the hour the fall change repeats runs once (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#daylist-savings-time">daylight saving</a>). <a href="https://cronradar.com/blog/cron-daylight-saving-time">Cron daylight saving time</a> compares this with other schedulers.</p>
<h2>Flexible Time Windows</h2>
<p>Every schedule must set <code>FlexibleTimeWindow</code>. <code>{"Mode": "OFF"}</code> invokes the target in the scheduled minute; <code>{"Mode": "FLEXIBLE", "MaximumWindowInMinutes": 15}</code> invokes it at some point in the 15 minutes after the scheduled time, which "improves the reliability of your schedule by dispersing your target invocations" (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html">flexible time windows</a>). The window can be 1 to 1,440 minutes (<a href="https://docs.aws.amazon.com/scheduler/latest/APIReference/API_FlexibleTimeWindow.html">FlexibleTimeWindow</a>). It only moves runs later, so anything waiting for the result must allow the scheduled time plus the window plus the run time.</p>
<h2>Delivery, Retries, and Dead-Letter Queues</h2>
<p>Scheduler "provides at-least-once event delivery to targets" (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html">features</a>), so one occurrence can reach the target twice. <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">Idempotent scheduled jobs</a> shows how to claim each period before doing its work; putting <code>&#x3C;aws.scheduler.scheduled-time></code> in the schedule's <code>Input</code> gives the job that period's key (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-context-attributes.html">context attributes</a>).</p>
<h3>Retry Policy</h3>
<p>When the call to the target fails, Scheduler retries with exponential backoff until either limit in the schedule's <code>RetryPolicy</code> runs out (<a href="https://docs.aws.amazon.com/scheduler/latest/APIReference/API_RetryPolicy.html">RetryPolicy</a>): <code>MaximumRetryAttempts</code>, 0 to 185, or <code>MaximumEventAgeInSeconds</code>, 60 to 86,400. Set both. A daily job retried for 24 hours can collide with the next day's run.</p>
<h3>Dead-Letter Queues</h3>
<p>When an invocation fails for good, Scheduler can send the event to the standard SQS queue (not FIFO) in <code>DeadLetterConfig</code>, if the execution role has <code>sqs:SendMessage</code> (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/configuring-schedule-dlq.html">DLQ</a>). Each message carries the target API's <code>ERROR_CODE</code> and <code>ERROR_MESSAGE</code>, <code>SCHEDULED_TIME</code>, and <code>RETRY_ATTEMPTS</code>. Without a DLQ, an event that exhausts its retries shows up only in the <code>InvocationDroppedCount</code> metric.</p>
<h3>Scheduler Failures vs. Job Failures</h3>
<p>Scheduler's part ends when the target's API accepts the call:</p>
<ul>
<li><strong>Lambda.</strong> Scheduler invokes functions asynchronously (<a href="https://docs.aws.amazon.com/lambda/latest/dg/with-eventbridge-scheduler.html">Lambda guide</a>), so the call succeeds once Lambda queues the event. If the function throws or times out, Lambda runs it "two more times, with a one-minute wait between the first two attempts, and two minutes between the second and third attempts" (<a href="https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html">async error handling</a>). The function's own <code>MaximumRetryAttempts</code> (0-2) and <code>MaximumEventAgeInSeconds</code> (60-21,600) tune that (<a href="https://docs.aws.amazon.com/lambda/latest/api/API_PutFunctionEventInvokeConfig.html">PutFunctionEventInvokeConfig</a>), and an on-failure <a href="https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-retain-records.html">destination</a> records events that fail every attempt.</li>
<li><strong>ECS.</strong> Scheduler calls <code>RunTask</code>. A task that exits non-zero or fails to pull its image isn't a Scheduler failure; ECS reports why it stopped in <code>ECS Task State Change</code> events (<a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_task_events.html">task events</a>).</li>
<li><strong>Step Functions.</strong> Scheduler calls <code>StartExecution</code>; an execution that fails later is the state machine's failure.</li>
</ul>
<p>So the schedule's DLQ holds failed invocations, such as a permissions error, while errors the function returns go to the function's own DLQ or destination (<a href="https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-schedulev2.html">SAM ScheduleV2</a>). <a href="https://cronradar.com/blog/cron-job-retries">Retrying failed cron jobs</a> covers choosing which layer retries.</p>
<h3>CloudWatch Metrics for EventBridge Scheduler</h3>
<p>Scheduler publishes these in the <code>AWS/Scheduler</code> namespace (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/monitoring-cloudwatch.html">metrics</a>):</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Emitted when</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>InvocationAttemptCount</code></td>
<td>Every invocation attempt</td>
</tr>
<tr>
<td><code>TargetErrorCount</code></td>
<td>The target API returns an exception</td>
</tr>
<tr>
<td><code>TargetErrorThrottledCount</code></td>
<td>The target API throttles the call</td>
</tr>
<tr>
<td><code>InvocationThrottleCount</code></td>
<td>Scheduler throttles invocations over your quota</td>
</tr>
<tr>
<td><code>InvocationDroppedCount</code></td>
<td>Scheduler stops trying after the retry policy runs out</td>
</tr>
<tr>
<td><code>InvocationsSentToDeadLetterCount</code></td>
<td>An event reaches the DLQ</td>
</tr>
<tr>
<td><code>InvocationsFailedToBeSentToDeadLetterCount</code></td>
<td>An event can't be written to the DLQ</td>
</tr>
</tbody>
</table>
<p>Two limits matter for alarms. The only dimension is <code>ScheduleGroup</code>, so an alarm watches a group, not a schedule; give an important schedule its own group (500 groups per Region by default) to alarm on it alone. And the metrics are best-effort: "A data point for a given minute might be delayed before being available through CloudWatch, or it might not be delivered at all."</p>
<h2>The Execution Role</h2>
<p>Every schedule's target names an IAM role, <code>RoleArn</code>, that Scheduler assumes to make the call. Its trust policy must allow <code>scheduler.amazonaws.com</code>, and AWS recommends conditions that tie it to your account and a schedule group (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/cross-service-confused-deputy-prevention.html">confused deputy prevention</a>):</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "scheduler.amazonaws.com" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "123456789012",
          "aws:SourceArn": "arn:aws:scheduler:us-east-1:123456789012:schedule-group/default"
        }
      }
    }
  ]
}
</code></pre>
<p>The permissions policy grants the target's API: <code>lambda:InvokeFunction</code>, <code>states:StartExecution</code>, or <code>ecs:RunTask</code> plus <code>iam:PassRole</code> for the task's execution and task roles (<a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/CWE_IAM_role.html">ECS</a>), and <code>sqs:SendMessage</code> for the DLQ. KMS-encrypted targets also need <code>kms:Decrypt</code> and <code>kms:GenerateDataKey</code> (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/troubleshooting.html#troubleshooting-perms">troubleshooting</a>).</p>
<p>These mistakes fail every invocation while the schedule looks fine; <code>TargetErrorCount</code> climbs and the target never logs anything:</p>
<ul>
<li><strong>The trust policy names <code>events.amazonaws.com</code>,</strong> the principal for rule roles such as ECS's <code>ecsEventsRole</code>. Scheduler can't assume it.</li>
<li><strong><code>aws:SourceArn</code> names a schedule.</strong> AWS: "The ARN you specify must be a schedule group."</li>
<li><strong>The permission is on the function, not the role.</strong> A Lambda resource-based policy letting <code>events.amazonaws.com</code> invoke the function, the usual setup for a rule, grants nothing to Scheduler's role.</li>
<li><strong>The role can't pass the ECS task's roles,</strong> so <code>RunTask</code> is denied.</li>
</ul>
<p>If <code>TargetErrorCount</code> climbs while the DLQ stays empty, AWS says that "likely indicates a permissions issue preventing EventBridge Scheduler from writing to the DLQ itself."</p>
<h2>Why an EventBridge Schedule Didn't Run</h2>
<table>
<thead>
<tr>
<th>Cause</th>
<th>What to check</th>
</tr>
</thead>
<tbody>
<tr>
<td>Disabled, expired, or reset by an update</td>
<td><code>aws scheduler get-schedule --name order-export</code>: <code>State</code>, <code>StartDate</code>, <code>EndDate</code>, <code>ScheduleExpressionTimezone</code>. <code>UpdateSchedule</code> sets every optional field it omits "to its system-default value" (<a href="https://docs.aws.amazon.com/scheduler/latest/APIReference/API_UpdateSchedule.html">UpdateSchedule</a>), so an update without the zone moves the schedule to UTC</td>
</tr>
<tr>
<td>Crontab day numbers</td>
<td><code>1</code> is Sunday and <code>2</code> is Monday</td>
</tr>
<tr>
<td>Every invocation fails</td>
<td><code>TargetErrorCount</code> and the DLQ's <code>ERROR_CODE</code>: the execution role, or a universal target whose <code>Input</code> the target API rejects, which Scheduler "does not validate" at creation (<a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/troubleshooting.html#troubleshooting-usi-target-input">universal targets</a>)</td>
</tr>
<tr>
<td>Lambda accepted the event</td>
<td>The function's <code>Errors</code> and <code>AsyncEventsDropped</code> <a href="https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html">metrics</a> and its logs. At reserved concurrency 0, Lambda sends events to its DLQ or on-failure destination "without any retries"</td>
</tr>
</tbody>
</table>
<h2>Monitoring an EventBridge Scheduled Job</h2>
<p>Alarms on <code>TargetErrorCount</code>, <code>InvocationDroppedCount</code>, or <code>InvocationsSentToDeadLetterCount</code>, plus the function's <code>Errors</code>, catch a failed invocation and a job that throws. Two failures move none of them:</p>
<ul>
<li><strong>A schedule that stopped invoking.</strong> A disabled, deleted, or expired schedule makes no attempts and emits no errors. An alarm on <code>InvocationAttemptCount</code> that <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/alarms-and-missing-data.html">treats missing data as breaching</a> can notice silence, but only per schedule group and only as reliably as best-effort metrics.</li>
<li><strong>A run that succeeded at nothing.</strong> A function that returns normally after exporting zero rows is a success to Lambda.</li>
</ul>
<p>Both are the job of a <a href="https://cronradar.com/blog/cron-dead-mans-switch">dead man's switch</a>: the job reports each run to a monitor outside AWS that knows the schedule, and a missing report is the alert.</p>
<h3>Report Each Run to CronRadar Over HTTP</h3>
<p>There is no CronRadar SDK for EventBridge Scheduler and no auto-discovery, and CronRadar's Python SDK isn't available right now, so each target reports with its own HTTP check-ins. This handler for the <code>python3.14</code> runtime uses only the standard library:</p>
<pre><code class="language-python"># handler.py: the Lambda target of an EventBridge Scheduler schedule (python3.14).
import base64
import json
import os
import urllib.parse
import urllib.request

from orders_export import export_orders  # the job itself, deployed with this file

PING_URL = "https://cron.life/api/v1/ping/order-export"
SCHEDULE = "15 10 * * *"  # 06:15 New York is 10:15 UTC in summer, 11:15 in winter
GRACE_SECONDS = 4500  # the winter hour (3,600 s) plus 900 s of run time


def report(kind, run_id, message=None, outcomes=None):
    """Send one CronRadar check-in. Gives up after 5 seconds and never raises."""
    try:
        params = {"schedule": SCHEDULE, "gracePeriod": GRACE_SECONDS, "run_id": run_id}
        if message:
            params["message"] = message[:1000]
        query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
        token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
        headers = {"Authorization": f"Basic {token}"}
        body = None
        if outcomes is not None:
            body = json.dumps({"outcomes": outcomes}).encode()
            headers["Content-Type"] = "application/json"
        request = urllib.request.Request(
            f"{PING_URL}/{kind}?{query}", data=body, headers=headers, method="POST"
        )
        with urllib.request.urlopen(request, timeout=5):
            pass
    except Exception as error:  # a monitoring problem must never fail the job
        print(f"CronRadar {kind} check-in failed: {error!r}")


def handler(event, context):
    run_id = context.aws_request_id  # Lambda keeps it when it retries this event
    report("start", run_id)
    try:
        rows = export_orders(event.get("scheduled_time"), context)
    except Exception as error:
        message = f"{type(error).__name__}; see CloudWatch Logs for request {run_id}"
        report("fail", run_id, message)
        raise
    report("complete", run_id, outcomes={"rows_exported": rows})
    return {"rows_exported": rows}
</code></pre>
<ul>
<li><strong>Monitoring can't break the job.</strong> Each check-in gives up after 5 seconds (urllib applies the timeout to each blocking step, not the whole request), and its errors are logged and dropped. The job's exception is re-raised after <code>fail</code>, so Lambda still records and retries it. <code>fail</code> names only the exception type, since exception text can carry data that shouldn't leave your account.</li>
<li><strong>One <code>run_id</code> per event.</strong> Duplicate invocations "that have the same request ID indicate that the Lambda service retried the function" (<a href="https://repost.aws/knowledge-center/lambda-function-duplicate-invocations">re:Post</a>). The handler can't tell which attempt is last, so a failed first attempt alerts at once and a successful retry records the recovery.</li>
<li><strong>A hang has to stop itself.</strong> At the function's timeout, at most 900 seconds (<a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html">timeout</a>), Lambda ends the run before <code>fail</code> is sent. CronRadar flags a run as stuck only when its <code>start</code> is still unanswered at the next expected run plus the grace period, and every check-in moves that deadline forward, so a job that times out nightly may never be flagged. Have <code>export_orders</code> check <code>context.get_remaining_time_in_millis()</code> between batches and raise when under a minute remains.</li>
<li><strong>Outcomes catch the empty run.</strong> Add the expectation <code>rows_exported >= 1</code> in the monitor's settings, and a run that exports nothing opens an incident. Outcome values must be finite numbers or booleans, so <code>export_orders</code> returns an integer.</li>
<li><strong>The first check-in creates the monitor</strong>, and the next recreates it if deleted. <code>schedule</code> and <code>gracePeriod</code> apply only at creation; change an existing monitor in the dashboard.</li>
</ul>
<p>In AWS SAM, a <code>ScheduleV2</code> event creates the schedule, and an execution role when you omit <code>RoleArn</code>:</p>
<pre><code class="language-yaml">Transform: AWS::Serverless-2016-10-31
Resources:
  OrderExportFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: handler.handler
      Runtime: python3.14
      Timeout: 900
      Environment:
        Variables:
          CRONRADAR_API_KEY: '{{resolve:secretsmanager:cronradar/production:SecretString:api_key}}'
      Events:
        NightlyExport:
          Type: ScheduleV2
          Properties:
            Name: order-export
            ScheduleExpression: 'cron(15 6 * * ? *)'
            ScheduleExpressionTimezone: America/New_York
            FlexibleTimeWindow:
              Mode: 'OFF'
            Input: '{"scheduled_time": "&#x3C;aws.scheduler.scheduled-time>"}'
            RetryPolicy:
              MaximumRetryAttempts: 10
              MaximumEventAgeInSeconds: 3600
            DeadLetterConfig:
              Type: SQS
</code></pre>
<p>The <code>{{resolve:secretsmanager:...}}</code> reference copies the key from Secrets Manager into the environment at deploy time. CloudFormation reads it only when it creates or updates the function, so a rotated key needs a redeploy, and the value "may show up in the service whose resource it's being used in": anyone who can view the function's environment variables can read it (<a href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references-secretsmanager.html">dynamic references</a>). To avoid that, or to use a Parameter Store SecureString (<code>ssm-secure</code> references <a href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references-ssm-secure-strings.html">don't work</a> on Lambda functions), fetch it at run time with the <a href="https://docs.aws.amazon.com/lambda/latest/dg/with-secrets-manager.html">Parameters and Secrets Lambda Extension</a> and set <code>CRONRADAR_API_KEY</code> before the first check-in. An ECS task gets the variable from its task definition's <code>secrets</code> (<a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/secrets-envvar-secrets-manager.html">ECS secrets</a>). Use one CronRadar application and key per environment.</p>
<h3>Convert the Schedule for CronRadar</h3>
<p>CronRadar reads <a href="https://docs.cronradar.com/schedules">five-field cron in UTC</a>, with Sunday as 0 and no year, <code>?</code>, <code>L</code>, <code>W</code>, or <code>#</code>; when both day fields are restricted, a date must match both. Drop the year, replace <code>?</code> with <code>*</code>, subtract one from numeric days of week, and convert local times to UTC. For a zone with daylight saving, use the earlier UTC time and a grace period of at least an hour plus the run time.</p>
<table>
<thead>
<tr>
<th>EventBridge Scheduler</th>
<th>CronRadar <code>schedule</code></th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cron(30 2 * * ? *)</code></td>
<td><code>30 2 * * *</code></td>
<td>Year dropped, <code>?</code> becomes <code>*</code></td>
</tr>
<tr>
<td><code>cron(0 9 ? * 2-6 *)</code></td>
<td><code>0 9 * * 1-5</code></td>
<td>AWS 2-6 is Monday-Friday; names carry over unchanged</td>
</tr>
<tr>
<td><code>cron(0 3 ? * 1 *)</code></td>
<td><code>0 3 * * 0</code></td>
<td>Sunday is 1 on AWS, 0 on CronRadar</td>
</tr>
<tr>
<td><code>cron(15 6 * * ? *)</code> in <code>America/New_York</code></td>
<td><code>15 10 * * *</code>, grace of 3,600 + run time</td>
<td>The winter run arrives an hour later</td>
</tr>
<tr>
<td><code>cron(0 9 ? * 2#1 *)</code></td>
<td><code>0 9 1-7 * 1</code></td>
<td>Both fields must match: the first Monday</td>
</tr>
<tr>
<td><code>cron(0 12 * * ? 2027)</code></td>
<td><code>0 12 * * *</code></td>
<td>No year field; pause the monitor when the year ends</td>
</tr>
<tr>
<td><code>rate(5 minutes)</code></td>
<td><code>*/5 * * * *</code></td>
<td>Only after changing AWS to <code>cron(0/5 * * * ? *)</code></td>
</tr>
</tbody>
</table>
<p>A <code>rate()</code> schedule's clock times depend on when it started, so replace it with the <code>cron()</code> it matches. <code>L</code>, <code>W</code>, <code>6L</code>, and rates such as <code>rate(10 days)</code> have no five-field form: schedule the AWS side on every candidate date, let the job decide, and check in on every run, including early exits, which mustn't fail an outcome expectation. For the last day of the month, that's <code>cron(0 23 28-31 * ? *)</code> on AWS, <code>0 23 28-31 * *</code> on CronRadar, and this check on the <code>scheduled_time</code> from the <code>Input</code>:</p>
<pre><code class="language-python">from datetime import datetime, timedelta


def is_last_day_of_month(scheduled_time):
    """scheduled_time is &#x3C;aws.scheduler.scheduled-time>, such as 2026-10-31T23:00:00Z."""
    day = datetime.fromisoformat(scheduled_time).date()
    return (day + timedelta(days=1)).day == 1
</code></pre>
<p>For the last Friday, schedule every Friday and run when seven days later is in the next month; for <code>15W</code>, schedule the 14th through 16th. Then invoke the function once, confirm <code>order-export</code> shows a <code>success</code> in the right CronRadar application, and let a scheduled run arrive.</p>
<h2>Frequently Asked Questions</h2>
<h3>What is the cron format for EventBridge Scheduler?</h3>
<p>Six fields inside <code>cron()</code>: minutes, hours, day-of-month, month, day-of-week, and year. One day field must be <code>?</code>, day-of-week runs 1-7 from Sunday, and <code>L</code>, <code>W</code>, and <code>#</code> are supported. Daily at 02:30 is <code>cron(30 2 * * ? *)</code>, in UTC unless the schedule sets <code>ScheduleExpressionTimezone</code>.</p>
<h3>What is the difference between EventBridge Scheduler and EventBridge rules?</h3>
<p>Scheduled rules are the legacy option: UTC only, recurring only, and 300 per event bus in most Regions. EventBridge Scheduler adds time zones, one-time schedules, flexible time windows, universal targets, and a default quota of 10 million schedules per Region. AWS recommends Scheduler.</p>
<h3>Does EventBridge Scheduler retry failed invocations?</h3>
<p>Yes, when the call to the target fails: up to 185 retries within at most 24 hours, with exponential backoff, then an optional SQS dead-letter queue. It doesn't retry the job's own failure: Lambda retries a function that throws, two more times by default, and an ECS task's non-zero exit is ECS's to report, not Scheduler's to retry.</p>
<h3>Why didn't my EventBridge schedule run?</h3>
<p>Check its state, dates, and time zone with <code>aws scheduler get-schedule</code>; an <code>UpdateSchedule</code> call resets every optional field it omits. Then check day-of-week numbering, where 2 is Monday, the daylight saving gap, and <code>TargetErrorCount</code>, which climbs when the execution role or target input is wrong.</p>
<h3>How do I monitor a scheduled Lambda function?</h3>
<p>Alarm on the function's <code>Errors</code> and Scheduler's <code>TargetErrorCount</code>, and have the function check in with a monitor outside AWS. With CronRadar, the handler reports <code>start</code>, <code>complete</code>, and <code>fail</code> over HTTP with a five-field UTC schedule, so a run that never happens alerts once its grace period passes.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>eventbridge scheduler cron</category>
      <category>aws cron expression</category>
      <category>eventbridge scheduler vs eventbridge rules</category>
      <category>aws scheduled lambda</category>
      <category>eventbridge scheduler retry policy</category>
      <category>eventbridge scheduler flexible time window</category>
    </item>
    <item>
      <title><![CDATA[Azure Functions Timer Trigger: NCRONTAB, Missed Runs, and Alerts]]></title>
      <link>https://cronradar.com/blog/azure-functions-timer-trigger</link>
      <guid>https://cronradar.com/blog/azure-functions-timer-trigger</guid>
      <description><![CDATA[How the Azure Functions timer trigger reads NCRONTAB schedules and time zones, why runs get missed or duplicated, and how to get alerted when one never starts.]]></description>
      <content:encoded><![CDATA[<p><strong>An Azure Functions timer trigger runs a function on an NCRONTAB schedule: six fields with seconds first, <code>{second} {minute} {hour} {day} {month} {day-of-week}</code>, evaluated in UTC unless the app sets <code>WEBSITE_TIME_ZONE</code>.</strong> <code>0 */5 * * * *</code> runs every five minutes and <code>0 30 9 * * 1-5</code> runs at 09:30 on weekdays. Only one instance runs a given timer, even when the app scales out, because the trigger holds a blob lease in the app's storage account. If the app is down when a run is due, the trigger makes up one run when it starts again, or none, and nothing alerts you either way.</p>
<p>This guide covers Functions runtime 4.x and the .NET isolated worker model (<code>Microsoft.Azure.Functions.Worker.Extensions.Timer</code> 4.3.1, current as of October 24, 2026). The trigger runs in the Functions host; source links below point to its <a href="https://github.com/Azure/azure-webjobs-sdk-extensions/tree/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers">code in azure-webjobs-sdk-extensions</a>.</p>
<h2>Isolated Worker or In-Process, and the Plans That Run Timers</h2>
<p><a href="https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model">Support for the in-process model ends on November 10, 2026</a>. The in-process model runs .NET 8 at most, and Flex Consumption doesn't support it at all (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-in-process-differences">model comparison</a>), so every example here uses the isolated worker model. Migrating a timer function mostly means adding the <code>Microsoft.Azure.Functions.Worker.Extensions.Timer</code> package and replacing <code>[FunctionName]</code> with <code>[Function]</code>.</p>
<p>Plan details from Microsoft's <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-scale">hosting comparison</a>, checked October 24, 2026:</p>
<table>
<thead>
<tr>
<th>Plan</th>
<th>OS</th>
<th><code>functionTimeout</code> default / max</th>
<th><code>WEBSITE_TIME_ZONE</code></th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Flex Consumption</td>
<td>Linux</td>
<td>30 min / unbounded</td>
<td>Not supported</td>
<td>Recommended serverless plan; no slots</td>
</tr>
<tr>
<td>Consumption (legacy)</td>
<td>Windows; Linux retires September 30, 2028</td>
<td>5 min / 10 min</td>
<td>Windows only</td>
<td>The 10-minute ceiling caps long jobs</td>
</tr>
<tr>
<td>Premium</td>
<td>Windows, Linux</td>
<td>30 min / unbounded</td>
<td>Supported</td>
<td>Always-ready instances</td>
</tr>
<tr>
<td>Dedicated (App Service)</td>
<td>Windows, Linux</td>
<td>30 min / unbounded</td>
<td>Supported</td>
<td>Needs Always On, or the runtime idles</td>
</tr>
</tbody>
</table>
<p>"Unbounded" still has edges: a running execution gets 60 minutes during scale-in on Flex Consumption and Premium, and 10 minutes during platform updates.</p>
<h2>NCRONTAB Syntax</h2>
<p>Functions parses schedules with the <a href="https://github.com/atifaziz/NCrontab">NCrontab library</a>. The six fields are second (0-59), minute, hour, day of month, month (1-12 or <code>Jan</code>-<code>Dec</code>), and day of week (0-6 with Sunday as 0, or <code>Sun</code>-<code>Sat</code>):</p>
<table>
<thead>
<tr>
<th>NCRONTAB</th>
<th>Runs</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0 */5 * * * *</code></td>
<td>Every five minutes, at second 0</td>
</tr>
<tr>
<td><code>0 0 */6 * * *</code></td>
<td>00:00, 06:00, 12:00, and 18:00</td>
</tr>
<tr>
<td><code>0 0 2 * * *</code></td>
<td>Daily at 02:00</td>
</tr>
<tr>
<td><code>0 30 9 * * 1-5</code></td>
<td>09:30, Monday through Friday</td>
</tr>
<tr>
<td><code>0 0 9 1-7 * Mon</code></td>
<td>09:00 on the first Monday of the month</td>
</tr>
<tr>
<td><code>*/30 * * * * *</code></td>
<td>Every 30 seconds</td>
</tr>
</tbody>
</table>
<p><code>*</code>, <code>,</code>, <code>-</code>, and <code>/</code> work as in standard cron, covered in <a href="https://cronradar.com/blog/cron-expression">cron expression syntax</a>, and names are case-insensitive English (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#ncrontab-expressions">timer trigger reference</a>). Two rules differ from Linux cron:</p>
<ul>
<li><strong>Five fields are accepted too, and they mean something else.</strong> The runtime reads an expression with exactly five fields as minute-first cron and anything else as including seconds (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/CronSchedule.cs">CronSchedule.cs</a>). Dropping one <code>*</code> from <code>0 */5 * * * *</code> gives <code>0 */5 * * *</code>: minute 0 of every fifth hour, not every five minutes, and no error.</li>
<li><strong>Both day fields must match.</strong> NCrontab finds a date matching the day of month, then checks the day of week (<a href="https://github.com/atifaziz/NCrontab/blob/d122d586a0665580f3fde9b8cfc03c5e3abf1909/NCrontab/CrontabSchedule.cs">CrontabSchedule.cs</a>), so <code>0 0 9 1-7 * Mon</code> is the first Monday. Linux cron runs when either field matches.</li>
</ul>
<h3>TimeSpan Schedules</h3>
<p>The schedule can also be a <code>TimeSpan</code> string such as <code>01:00:00</code>, which Microsoft documents only for apps that run on an App Service plan (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#timespan">TimeSpan</a>). It sets the interval between invocations rather than clock times, and when a run takes longer than the interval, the next one starts immediately. On Consumption and Flex Consumption, use NCRONTAB.</p>
<h3>Schedules in App Settings</h3>
<p>Wrap an app setting name in percent signs to keep the expression out of code: <code>[TimerTrigger("%NightlyCleanupSchedule%")]</code>, with an app setting <code>NightlyCleanupSchedule</code> set to <code>0 0 2 * * *</code>. If the setting doesn't exist, the host throws <code>'%NightlyCleanupSchedule%' does not resolve to a value</code> and the timer doesn't start (<a href="https://github.com/Azure/azure-webjobs-sdk/blob/b793625b889b8cd1b4cb9d55624a1e72dd5faf7a/src/Microsoft.Azure.WebJobs.Host/NameResolverExtensions.cs">NameResolverExtensions.cs</a>). Changing an app setting restarts the app (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings">app settings</a>).</p>
<h2>Time Zones and Daylight Saving Time</h2>
<p>Schedules run in UTC by default. For local time, add an app setting named <code>WEBSITE_TIME_ZONE</code> (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#ncrontab-time-zones">NCRONTAB time zones</a>):</p>
<ul>
<li><strong>Windows, any plan:</strong> a Windows time zone name from <code>tzutil.exe /L</code>, such as <code>Eastern Standard Time</code>.</li>
<li><strong>Linux on Premium or Dedicated:</strong> a <a href="https://www.iana.org/time-zones">tz database</a> name, such as <code>America/New_York</code>.</li>
<li><strong>Linux on Flex Consumption or Consumption:</strong> not supported. Microsoft warns that setting <code>WEBSITE_TIME_ZONE</code> or <code>TZ</code> there can cause SSL-related issues and stop your app's metrics.</li>
</ul>
<p>With the setting in place, the schedule follows daylight saving time: <code>0 0 10 * * *</code> on <code>America/New_York</code> fires at 10:00 local all year. On the night clocks go back, the extension skips the second occurrence of a repeated time, so a job at 01:30 runs once (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/CronSchedule.cs">CronSchedule.cs</a>). The reference doesn't say what a job scheduled inside the skipped spring hour does, so keep daily jobs out of the hour around the change, or leave the app on UTC.</p>
<h2>One Run at a Time: The Singleton Lease</h2>
<p>The timer's listener is a singleton (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/TimerListener.cs">TimerListener.cs</a>): before an instance runs the schedule, it must hold a blob lease under <code>locks/&#x3C;host-id>/</code> in the <code>azure-webjobs-hosts</code> container of the <code>AzureWebJobsStorage</code> account, and other instances wait (<a href="https://github.com/Azure/azure-webjobs-sdk/blob/b793625b889b8cd1b4cb9d55624a1e72dd5faf7a/src/Microsoft.Azure.WebJobs.Host.Storage/Singleton/BlobLeaseDistributedLockManager.cs">BlobLeaseDistributedLockManager.cs</a>). That's how "only a single instance of a timer-triggered function is run across all instances" (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#scale-out">scale-out</a>). It's also why timer triggers depend on blob storage everywhere except local Core Tools, and why an identity-based <code>AzureWebJobsStorage</code> connection needs the Storage Blob Data Owner role (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#connections">connections</a>).</p>
<p>The lease is keyed by host ID, and that's where it breaks:</p>
<ul>
<li><strong>Two apps, one storage account, one host ID:</strong> only one timer runs (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#function-apps-sharing-storage">sharing storage</a>). Outside Flex Consumption, the host ID defaults to the app name truncated to 32 characters, so <code>contoso-billing-reconciliation-jobs-prod</code> and <code>contoso-billing-reconciliation-jobs-test</code> collide. Runtime 4.x detects that case, logs <code>AZFD0004</code>, and stops the host (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/storage-considerations#host-id-considerations">host ID considerations</a>), but it checks only truncated default IDs (<a href="https://github.com/Azure/azure-functions-host/blob/30c29d7b32141e3e137dd6339958e978cbf06529/src/WebJobs.Script/Host/ScriptHostIdProvider.cs">ScriptHostIdProvider.cs</a>): two apps given the same explicit <code>AzureFunctionsWebHost__hostid</code> silently share one timer. Flex Consumption generates unique host IDs.</li>
<li><strong>A staging slot runs its own copy.</strong> With its own host ID it holds its own lease, so its timer fires alongside production's. Disable the function there with an <code>AzureWebJobs.&#x3C;FunctionName>.Disabled</code> app setting marked as a deployment slot setting (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/disable-function#disable-functions-in-a-slot">disable in a slot</a>).</li>
</ul>
<h2>Missed Runs: UseMonitor, ScheduleStatus, and IsPastDue</h2>
<p><code>UseMonitor</code> defaults to <code>true</code> for schedules that recur once a minute or less often, and to <code>false</code> for schedules that fire more than once a minute (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#attributes">attributes</a>). With it on, the host stores a status blob at <code>timers/&#x3C;host-id>/&#x3C;function>/status</code> in <code>azure-webjobs-hosts</code> (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions.Timers.Storage/StorageScheduleMonitor.cs">StorageScheduleMonitor.cs</a>) and passes it to the function in <code>TimerInfo</code>:</p>
<table>
<thead>
<tr>
<th>Property</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ScheduleStatus.Last</code></td>
<td>The last recorded run</td>
</tr>
<tr>
<td><code>ScheduleStatus.Next</code></td>
<td>The next expected occurrence</td>
</tr>
<tr>
<td><code>ScheduleStatus.LastUpdated</code></td>
<td>When the record was written; used to recompute <code>Next</code> after a restart</td>
</tr>
<tr>
<td><code>IsPastDue</code></td>
<td>This invocation is later than scheduled</td>
</tr>
</tbody>
</table>
<p>In the isolated worker, <code>ScheduleStatus</code> is <code>null</code> when <code>UseMonitor</code> is off (<a href="https://github.com/Azure/azure-functions-dotnet-worker/blob/bbf17dfc9eb805f555e135ca775482427ef70262/extensions/Worker.Extensions.Timer/src/TimerInfo.cs">TimerInfo.cs</a>). When the listener starts, it compares the stored <code>Next</code> with the clock (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/TimerListener.cs">TimerListener.cs</a>, <a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Scheduling/ScheduleMonitor.cs">ScheduleMonitor.cs</a>):</p>
<ul>
<li><strong>If <code>Next</code> has passed,</strong> it runs the function once, immediately, with <code>IsPastDue</code> set. That's one catch-up run however many occurrences were missed: a daily job whose app was down from Friday night to Monday morning runs once on Monday.</li>
<li><strong>If the schedule expression changed,</strong> the stored status is reset and there is no catch-up run.</li>
<li><strong>With <code>UseMonitor</code> off,</strong> missed occurrences are skipped without a trace.</li>
</ul>
<p>The status is written only after an invocation returns. If the host process dies mid-run, that occurrence stays unrecorded, and the next listener start treats it as past due and runs it again. Together with slots and retries, that's why Microsoft recommends <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-idempotent">designing functions for identical input</a>: a timer job should be safe to run twice.</p>
<h2>RunOnStartup</h2>
<p><code>RunOnStartup = true</code> invokes the function whenever the runtime starts: when the app wakes from idle, restarts after a change, or scales out. Microsoft says it "should rarely if ever be set to <code>true</code>, especially in production," because it runs code at unpredictable times, and on the Consumption plan the extra invocations on every scale event can raise costs significantly (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#attributes">attributes and caution</a>). To run a job once after a deployment, <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-manually-run-non-http">invoke it manually</a> instead.</p>
<h2>Timeouts, Retries, and Overlapping Runs</h2>
<p><strong>Timeouts.</strong> <code>functionTimeout</code> in <code>host.json</code> (for example <code>"functionTimeout": "00:45:00"</code>) caps each execution; the defaults are in the plan table. When an execution exceeds it, the runtime logs a timeout error and restarts the language worker process (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-scale#function-app-timeout-duration">timeout duration</a>), ending any work in flight.</p>
<p><strong>Retries.</strong> By default a failed timer run isn't retried; the next attempt is the next occurrence (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#retry-behavior">retry behavior</a>). Timer triggers do support function-level retry policies, <code>[FixedDelayRetry]</code> and <code>[ExponentialBackoffRetry]</code>, in the isolated worker with <code>Microsoft.Azure.Functions.Worker.Extensions.Timer</code> 4.2.0 or later. The retry count lives in the instance's memory and a timer retry doesn't resume on another instance, so you can get fewer retries than you asked for (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-error-pages#retry-policies">retry policies</a>).</p>
<p><strong>Overlap.</strong> A timer never overlaps itself within one app: it "will not trigger again if there is an outstanding invocation still running" (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer#scale-out">scale-out</a>). The listener computes the next occurrence from the time a run started and arms the timer only after the run returns (<a href="https://github.com/Azure/azure-webjobs-sdk-extensions/blob/7b83fc2f4ffb88c71db2564393fbdef544327233/src/WebJobs.Extensions/Extensions/Timers/Listener/TimerListener.cs">TimerListener.cs</a>). An every-five-minutes job that runs from 10:00 to 10:12 runs again at 10:12, immediately and without <code>IsPastDue</code>, then at 10:15. The 10:05 and 10:10 occurrences collapse into that one late run.</p>
<h2>Why an Azure Functions Timer Trigger Isn't Firing</h2>
<table>
<thead>
<tr>
<th>Cause</th>
<th>What to check</th>
</tr>
</thead>
<tbody>
<tr>
<td>The app is stopped or idle</td>
<td>App state; on a Dedicated plan, <a href="https://learn.microsoft.com/en-us/azure/azure-functions/dedicated-plan#always-on">Always On</a>, which is set per slot</td>
</tr>
<tr>
<td>Triggers weren't synced after deploying</td>
<td>External package URL, Local Git, and FTPS deployments need a <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-deployment-technologies#trigger-syncing">manual trigger sync</a></td>
</tr>
<tr>
<td>The function is disabled</td>
<td><code>AzureWebJobs.&#x3C;FunctionName>.Disabled</code> set to <code>true</code>; one added in staging without <strong>Deployment slot setting</strong> moves to production in a swap</td>
</tr>
<tr>
<td>Host storage is unreachable</td>
<td>The <code>AzureWebJobsStorage</code> connection, storage network rules, rotated keys, the identity's role</td>
</tr>
<tr>
<td>Another app holds the lease</td>
<td>Shared storage and a matching host ID; <code>AZFD0004</code> in the logs</td>
</tr>
<tr>
<td>The schedule is in another zone</td>
<td>UTC by default; no <code>WEBSITE_TIME_ZONE</code> on Flex Consumption or Linux Consumption</td>
</tr>
<tr>
<td>Five fields where you meant six</td>
<td><code>0 */5 * * *</code> runs at minute 0 of every fifth hour</td>
</tr>
<tr>
<td>The schedule doesn't load</td>
<td>An invalid expression (the portal's Test/Run returns a 404) or an unresolved <code>%Setting%</code>; Application Insights has the error</td>
</tr>
<tr>
<td>It ran, but you can't see it</td>
<td>Sampling, or a <code>Host.Results</code> log level above <code>Information</code>, drops rows from <code>requests</code> (<a href="https://learn.microsoft.com/en-us/azure/azure-functions/configure-monitoring#configure-categories">log categories</a>)</td>
</tr>
</tbody>
</table>
<h2>Alerting When a Timer Run Fails or Never Starts</h2>
<p>Application Insights records each invocation in the <code>requests</code> table, so an alert on failed requests catches runs that throw or time out. A run that never starts writes nothing for that alert to fire on. A log search alert can fire when a function's request count drops to zero, but each query has to encode its function's schedule, and Microsoft notes that log search alerts "work best when you're trying to detect specific data in the logs, as opposed to when you're trying to detect a lack of data" (<a href="https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-types#log-search-alerts">alert types</a>).</p>
<p>The pattern built for absence is a <a href="https://cronradar.com/blog/cron-dead-mans-switch">dead man's switch</a>: the function checks in on every run, and a service outside Azure that knows the schedule alerts when a check-in is late.</p>
<h3>Add CronRadar to an Isolated Worker Timer Function</h3>
<p>Azure Functions has no CronRadar auto-discovery; each timer function reports through the base <a href="https://docs.cronradar.com/integrations/dotnet">CronRadar .NET SDK</a>. NuGet installs <code>CronRadar</code> 1.0.13 (checked October 24, 2026):</p>
<pre><code class="language-bash">dotnet add package CronRadar
</code></pre>
<p>Create one CronRadar application per environment and put its API key in an app setting named <code>CRONRADAR_API_KEY</code>; app settings reach your code as <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings#use-application-settings">environment variables</a>, where the SDK reads it. A <a href="https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references">Key Vault reference</a> keeps the key out of the app's configuration, as long as the app's managed identity can read the secret:</p>
<pre><code class="language-bash">az functionapp config appsettings set \
  --name contoso-maintenance --resource-group contoso-prod \
  --settings "CRONRADAR_API_KEY=@Microsoft.KeyVault(SecretUri=https://contoso-prod-kv.vault.azure.net/secrets/cronradar-api-key)"
</code></pre>
<p>For local runs, put a development application's key in <code>local.settings.json</code> under <code>Values</code>. Then wrap the job:</p>
<pre><code class="language-csharp">using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace Contoso.Maintenance;

// Your service that does the cleanup; register an implementation in Program.cs.
public interface ISessionCleanup
{
    Task DeleteExpiredAsync(CancellationToken cancellationToken);
}

public class NightlyCleanup(ISessionCleanup cleanup, ILogger&#x3C;NightlyCleanup> logger)
{
    [Function(nameof(NightlyCleanup))]
    public async Task Run(
        [TimerTrigger("0 0 2 * * *")] TimerInfo timer, // 02:00:00 UTC every day
        CancellationToken cancellationToken)
    {
        if (timer.IsPastDue)
        {
            logger.LogWarning("Catch-up run for a missed occurrence. Last recorded run: {Last}",
                timer.ScheduleStatus?.Last);
        }

        // The class shares its namespace's name, so qualify it fully.
        await CronRadar.CronRadar.WrapAsync("nightly-cleanup", async () =>
        {
            // Give up well before functionTimeout so a hang becomes a reported failure.
            using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            deadline.CancelAfter(TimeSpan.FromMinutes(20));
            await cleanup.DeleteExpiredAsync(deadline.Token);
        }, schedule: "0 2 * * *");
    }
}
</code></pre>
<p>The wrapper sends <code>start</code> before the work and <code>complete</code> after it returns. If the work throws, it sends <code>fail</code> with the exception message and rethrows the original exception, so Functions still records the failed invocation and a retry policy still sees it. Each check-in times out after 5 seconds and never throws: if cron.life is unreachable, the run takes up to about ten seconds longer and its result doesn't change. The <code>schedule</code> argument creates the monitor on the first run and recreates it if it's ever deleted.</p>
<p>The 20-minute deadline matters because of how CronRadar detects stuck runs. Every check-in, <code>start</code> included, moves the expected time to the next scheduled occurrence; if that time plus the grace period passes while the last event is a <code>start</code>, the monitor goes <code>critical</code> as stuck. A run cut off by <code>functionTimeout</code> may never send its <code>fail</code>, because the worker process restarts, and the next night's <code>start</code> resets the window, so a job that timed out every night might never be flagged as stuck. A deadline shorter than <code>functionTimeout</code> turns the hang into a <code>fail</code> that alerts immediately, as long as the work honors the token. On Consumption, with its 5-minute default, set it lower. A host shutdown cancels the same token and is reported as a failure too.</p>
<p>With a retry policy, each attempt is a separate invocation, so the first failed attempt alerts even if a retry succeeds; the retry's <code>complete</code> then records the recovery.</p>
<h3>Convert the Schedule for CronRadar</h3>
<p>CronRadar reads <a href="https://docs.cronradar.com/schedules">five-field cron in UTC</a>. Drop the seconds field and convert local times:</p>
<table>
<thead>
<tr>
<th>Functions schedule</th>
<th>CronRadar <code>schedule</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0 0 2 * * *</code></td>
<td><code>0 2 * * *</code></td>
</tr>
<tr>
<td><code>0 0 9 1-7 * Mon</code></td>
<td><code>0 9 1-7 * 1</code>; CronRadar also requires both day fields</td>
</tr>
<tr>
<td><code>30 0 2 * * *</code> (02:00:30)</td>
<td><code>0 2 * * *</code>; the grace period absorbs the 30 seconds</td>
</tr>
<tr>
<td><code>0 0 10 * * *</code> with <code>WEBSITE_TIME_ZONE=America/New_York</code></td>
<td><code>0 14 * * *</code> with a longer grace period</td>
</tr>
<tr>
<td><code>*/30 * * * * *</code></td>
<td>None: five fields can't express more than one run a minute</td>
</tr>
<tr>
<td><code>01:00:00</code> (<code>TimeSpan</code>)</td>
<td>None: interval schedules aren't accepted</td>
</tr>
</tbody>
</table>
<p>A five-field Functions schedule passes through unchanged if the app runs in UTC. Work that must run more than once a minute fits better in a long-running process; see <a href="https://cronradar.com/blog/dotnet-backgroundservice-scheduled-tasks">scheduled tasks with BackgroundService</a>.</p>
<p>For a <code>WEBSITE_TIME_ZONE</code> schedule that follows daylight saving time, use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration. 10:00 in New York is 14:00 UTC in summer and 15:00 UTC in winter, so send <code>0 14 * * *</code>. <code>WrapAsync</code> takes no grace period, and <code>DefaultGracePeriodSeconds</code> has no effect here: the <code>start</code> check-in carries only the schedule, and CronRadar creates the monitor with its 90-second default. Set the grace period in the CronRadar dashboard (Grace Period, in seconds), either by creating the monitor before the first run or by editing the one the first run creates.</p>
<p>In winter the monitor then shows <code>warning</code> for the hour before each run. Leaving the app on UTC avoids the whole problem.</p>
<p>Run the function once from the portal, confirm <code>nightly-cleanup</code> shows a <code>success</code> result in the right CronRadar application, then let one scheduled run arrive. The <a href="https://cronradar.com/dotnet">.NET monitoring overview</a> covers other .NET schedulers.</p>
<h2>Frequently Asked Questions</h2>
<h3>What cron format does an Azure Functions timer trigger use?</h3>
<p>NCRONTAB, with six fields and seconds first, so daily at 02:00 is <code>0 0 2 * * *</code>. A five-field expression is also accepted and read as standard minute-first cron. On an App Service plan, the schedule can instead be a <code>TimeSpan</code> interval such as <code>01:00:00</code>.</p>
<h3>How do I set the time zone for an Azure Functions timer?</h3>
<p>Add a <code>WEBSITE_TIME_ZONE</code> app setting: a Windows time zone name on Windows, or a tz database name such as <code>America/New_York</code> on Linux Premium and Dedicated. Flex Consumption and Linux Consumption don't support it, so write those schedules in UTC.</p>
<h3>Why didn't my Azure Functions timer trigger run?</h3>
<p>Usually an idle Dedicated app without Always On, triggers not synced after deployment, a disabled function, unreachable host storage, a host ID collision, or a schedule in an unexpected zone. If the host was down at the scheduled time, <code>UseMonitor</code> makes up one run at startup; without it, the run is skipped.</p>
<h3>Can a timer-triggered function run twice at the same time?</h3>
<p>Not within one function app: a blob lease keeps the timer on one instance, and it won't fire while the previous run is still going. Duplicates come from outside that lease: a staging slot, two apps with different host IDs running the same code, <code>RunOnStartup</code>, or a past-due rerun after the host died mid-run.</p>
<h3>How do I get alerted when a timer function doesn't run?</h3>
<p>Have the function check in with a monitor outside Azure on every run. With CronRadar, wrap the job in <code>CronRadar.CronRadar.WrapAsync</code> with the five-field UTC schedule: a missing check-in raises an alert after the grace period, and an exception raises one immediately.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>azure functions timer trigger</category>
      <category>azure function timer trigger cron expression</category>
      <category>ncrontab azure functions</category>
      <category>azure function timer trigger not firing</category>
      <category>azure functions RunOnStartup</category>
      <category>azure functions timer trigger time zone</category>
    </item>
    <item>
      <title><![CDATA[BullMQ Repeatable Jobs and Job Schedulers: What Breaks in Production]]></title>
      <link>https://cronradar.com/blog/bullmq-repeatable-jobs</link>
      <guid>https://cronradar.com/blog/bullmq-repeatable-jobs</guid>
      <description><![CDATA[How BullMQ Job Schedulers replaced repeatable jobs, what happens after downtime, why jobs stall, and how to alert when a scheduled job fails or never runs.]]></description>
      <content:encoded><![CDATA[<p><strong>In current BullMQ, a repeatable job is a Job Scheduler: <code>queue.upsertJobScheduler(id, { pattern or every }, template)</code> creates or updates the schedule by ID, and BullMQ keeps exactly one delayed job waiting for the next run.</strong> The older API, <code>queue.add(name, data, { repeat })</code>, was deprecated in BullMQ 5.16.0 and <a href="https://github.com/taskforcesh/bullmq/releases/tag/v6.0.0">removed in 6.0.0</a> on July 30, 2026. The failures that hurt are quiet ones: no worker running, a schedule gone from Redis, a processor that never returns. None of them produces a <code>failed</code> event, so something outside the process has to notice.</p>
<p>This guide covers BullMQ 6.4.2, the latest release on npm (checked October 25, 2026), with notes for the still-maintained 5.x line. For in-process schedulers such as node-cron, see <a href="https://cronradar.com/blog/node-js-cron-monitoring">Node.js cron job monitoring</a>. To see how BullMQ compares with Agenda and the in-process libraries, see <a href="https://cronradar.com/blog/node-js-schedulers-compared">Node.js schedulers compared</a>.</p>
<h2>How a Job Scheduler Produces Each Run</h2>
<p>BullMQ 6 no longer installs ioredis for you; it is an <a href="https://github.com/taskforcesh/bullmq/releases/tag/v6.0.0">optional peer dependency</a> that Redis users install explicitly:</p>
<pre><code class="language-bash">npm install bullmq ioredis
</code></pre>
<pre><code class="language-javascript">import { Queue } from 'bullmq'

const queue = new Queue('reports', { connection: { host: '127.0.0.1', port: 6379 } })

// Every day at 02:30 UTC. Calling this again with the same ID updates it.
await queue.upsertJobScheduler(
  'nightly-invoice-export',
  { pattern: '30 2 * * *', tz: 'UTC' },
  { name: 'invoice-export', data: { format: 'csv' }, opts: { attempts: 3 } },
)
</code></pre>
<p>The call returns the first job, in the delayed state. From then on, per the <a href="https://docs.bullmq.io/guide/job-schedulers">Job Schedulers guide</a>, there is always one delayed job for the scheduler, and "the scheduler will only generate new jobs when the last job begins processing." The worker that picks up a run creates the next one (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L989-L1014">worker.ts</a>), so a busy queue delays later runs. Each run gets the ID <code>repeat:&#x3C;scheduler id>:&#x3C;timestamp></code>; you cannot set a custom job ID.</p>
<h3>Pattern Syntax and Time Zones</h3>
<p>Patterns are parsed by <a href="https://www.npmjs.com/package/cron-parser">cron-parser</a> (BullMQ 6.4.2 depends on cron-parser 5.10.2). Five fields work as in crontab, and an optional sixth field at the front is seconds: the <a href="https://docs.bullmq.io/guide/job-schedulers/repeat-strategies">repeat strategies page</a> writes 09:00 on weekdays as <code>0 0 9 * * 1-5</code>. cron-parser also accepts <code>L</code>, <code>#</code>, <code>?</code>, <code>H</code>, <code>7</code> for Sunday, and shortcuts such as <code>@daily</code>.</p>
<p>Set <code>tz</code> explicitly. Without it, cron-parser builds dates in the local zone of the process doing the calculation (<a href="https://github.com/harrisiirak/cron-parser/blob/v5.10.2/src/CronDate.ts#L34-L35">CronDate.ts</a>), and that is the producer for the first run and each worker after that. A fleet with mixed <code>TZ</code> settings computes different run times. BullMQ 6 removed the old <code>utc</code> option; <a href="https://docs.bullmq.io/guide/job-schedulers/repeat-options">use <code>tz: 'UTC'</code></a>.</p>
<h3>Intervals, Immediate Runs, and Limits</h3>
<ul>
<li><code>every</code> takes milliseconds and can't be combined with <code>pattern</code>. A new <code>every</code> scheduler runs its first job immediately (the <code>immediately</code> flag was deprecated for this in 5.19.0), and in 6.4.2 later runs are spaced from that first run rather than aligned to the clock (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/includes/getJobSchedulerEveryNextMillis.lua">getJobSchedulerEveryNextMillis.lua</a>), although the repeat options page still describes clock alignment.</li>
<li><code>immediately: true</code> on a <code>pattern</code> scheduler makes the next run happen now. Left in startup code, it adds an off-schedule run every time the upsert executes (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L517-L539">job-scheduler.ts</a>).</li>
<li><code>startDate</code>, <code>endDate</code>, and <code>limit</code> bound the schedule; past <code>endDate</code> or <code>limit</code>, no more jobs are produced.</li>
</ul>
<h2>Repeatable Jobs Are Gone in BullMQ 6</h2>
<p>BullMQ 6 removed <code>Queue.add(..., { repeat })</code>, <code>addBulk</code> with <code>repeat</code>, the <code>Repeat</code> class, <code>getRepeatableJobs()</code>, <code>removeRepeatable()</code>, and <code>removeRepeatableByKey()</code> (<a href="https://docs.bullmq.io/guide/migrations/migrate-from-v5-to-v6">migration guide</a>). Legacy repeatable data left in Redis is not converted. When a 6.x worker picks up a job from a legacy repeatable, it runs that job, emits an <code>error</code> event that begins "Failed to add repeatable job for next iteration", and schedules nothing after it (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L1019-L1026">worker.ts</a>). Without an <code>error</code> listener, the schedule ends quietly after one more run.</p>
<p>Migrate while still on the latest 5.x release: read each definition with <code>getRepeatableJobs()</code>, recreate it with <code>upsertJobScheduler()</code>, confirm it with <code>getJobSchedulers()</code>, remove the old one with <code>removeRepeatableByKey()</code>, and only then deploy 6.x producers and workers.</p>
<h3>Duplicate Schedules: Old Keys and Renamed IDs</h3>
<p>A legacy repeatable was identified by a key built from its name, ID, end date, time zone, and pattern (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L23-L28">job-scheduler.ts</a>). Change the pattern in a deploy and <code>add</code> created a second repeatable while the first kept firing, until someone removed it by key.</p>
<p><code>upsertJobScheduler</code> fixes that for a stable ID: it replaces the pending job and stores the new settings. But schedulers live in Redis, not in your code, so renaming an ID creates a second scheduler, and deleting the upsert call removes nothing. The example below reconciles the queue against its definitions on every start.</p>
<h2>Missed Runs Collapse Into One After Downtime</h2>
<p>When no worker is running, the scheduler's single delayed job just waits. When a worker returns, that overdue job runs once, late, and the next run is calculated from the current time: for a pattern, the next occurrence after now (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L187-L188">job-scheduler.ts</a>); for <code>every</code>, the next slot after now (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/includes/getJobSchedulerEveryNextMillis.lua#L22-L37">getJobSchedulerEveryNextMillis.lua</a>). The <a href="https://docs.bullmq.io/guide/jobs/repeatable">legacy repeatable docs</a> say the same of the old API: jobs "will not accumulate". A daily job that was down for three days runs once. If each missed day must be processed, make the job work from a watermark, as described in <a href="https://cronradar.com/blog/missed-runs-after-downtime">missed runs after downtime</a>, and make reruns safe with <a href="https://cronradar.com/blog/idempotent-scheduled-jobs">idempotent scheduled jobs</a>.</p>
<figure class="not-prose my-8">
<div class="overflow-x-auto">
<svg viewBox="0 0 720 184" role="img" aria-label="Timeline of a daily 30 2 * * * Job Scheduler over five days: the day 1 run creates the next delayed job; with no worker running for three days, that one delayed job waits through the day 2, 3, and 4 times; when a worker returns it runs once, late, and the next run is computed from now, on day 5." class="h-auto w-full min-w-[560px]" font-family="inherit" font-size="13">
<defs>
<marker id="bullmq-repeatable-jobs-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 z" class="fill-foreground"/></marker>
</defs>
<text x="175" y="24" text-anchor="middle" font-size="12" class="fill-muted-foreground">day 1</text>
<text x="300" y="24" text-anchor="middle" font-size="12" class="fill-muted-foreground">day 2</text>
<text x="425" y="24" text-anchor="middle" font-size="12" class="fill-muted-foreground">day 3</text>
<text x="550" y="24" text-anchor="middle" font-size="12" class="fill-muted-foreground">day 4</text>
<text x="675" y="24" text-anchor="middle" font-size="12" class="fill-muted-foreground">day 5</text>
<text x="16" y="57" font-size="14" font-weight="600" class="fill-foreground">Scheduled</text>
<text x="16" y="74" font-size="12" class="fill-muted-foreground">30 2 * * *</text>
<text x="16" y="125" font-size="14" font-weight="600" class="fill-foreground">What runs</text>
<circle cx="175" cy="60" r="6" class="fill-background stroke-foreground" stroke-width="1.5"/>
<circle cx="300" cy="60" r="6" class="fill-background stroke-destructive" stroke-width="1.5"/>
<circle cx="425" cy="60" r="6" class="fill-background stroke-destructive" stroke-width="1.5"/>
<circle cx="550" cy="60" r="6" class="fill-background stroke-destructive" stroke-width="1.5"/>
<circle cx="675" cy="60" r="6" class="fill-background stroke-foreground" stroke-width="1.5"/>
<rect x="235" y="100" width="375" height="40" rx="4" class="fill-muted stroke-border" stroke-width="1" stroke-dasharray="4 4"/>
<text x="422.5" y="158" text-anchor="middle" font-size="12" class="fill-muted-foreground">no worker running for three days</text>
<path d="M300 66 V120 H602" fill="none" class="stroke-muted-foreground" stroke-width="1.5" stroke-dasharray="4 4"/>
<text x="452" y="114" text-anchor="middle" font-size="12" class="fill-muted-foreground">one delayed job waits</text>
<circle cx="175" cy="120" r="6" class="fill-success"/>
<line x1="181" y1="116" x2="293" y2="64" class="stroke-foreground" stroke-width="1.5" marker-end="url(#bullmq-repeatable-jobs-arrow)"/>
<text x="175" y="158" text-anchor="middle" font-size="12" class="fill-foreground">creates the next one</text>
<circle cx="610" cy="120" r="6" class="fill-success"/>
<line x1="615" y1="115" x2="669" y2="65" class="stroke-foreground" stroke-width="1.5" marker-end="url(#bullmq-repeatable-jobs-arrow)"/>
<text x="610" y="158" text-anchor="middle" font-size="12" class="fill-foreground">runs once, late</text>
<text x="610" y="174" text-anchor="middle" font-size="12" class="fill-muted-foreground">next from now</text>
<circle cx="675" cy="120" r="6" class="fill-success"/>
</svg>
</div>
<figcaption class="mt-3 text-sm leading-relaxed text-muted-foreground">The scheduler keeps one delayed job, and the worker that picks up a run creates the next. Three days without a worker produce one late run, not three, and the next run is calculated from the current time.</figcaption>
</figure>
<p>Upserting at startup can lose even that one late run. For a <code>pattern</code> scheduler, an upsert that finds the current run still delayed or waiting removes it and schedules the next future occurrence instead (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/addJobScheduler-11.lua#L120-L138">addJobScheduler-11.lua</a>). If the process that crashed at 02:25 restarts at 02:45 and upserts before its worker picks up the job, the 02:30 run never happens. The example below skips the upsert when the definition hasn't changed.</p>
<p>Because the next run is created when the current one starts, a run longer than the interval can overlap the next whenever you have several workers or <code>concurrency</code> above 1. For work that must never overlap, give it its own queue and call <a href="https://docs.bullmq.io/guide/queues/global-concurrency"><code>queue.setGlobalConcurrency(1)</code></a>; <a href="https://cronradar.com/blog/prevent-overlapping-cron-jobs">preventing overlapping cron jobs</a> covers lock-based alternatives.</p>
<h3>Redis Must Not Evict Keys</h3>
<p>BullMQ's <a href="https://docs.bullmq.io/guide/going-to-production">production guide</a> is blunt: set <code>maxmemory-policy</code> to <code>noeviction</code>, "the <strong>only</strong> setting that guarantees the correct behavior of the queues." BullMQ checks on connect and prints <code>IMPORTANT! Eviction policy is allkeys-lru. It should be "noeviction"</code> when it isn't (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/redis-connection.ts#L873-L879">redis-connection.ts</a>). Under an evicting policy, Redis can drop the keys that hold a scheduler or its pending job, and the schedule can stop without a clear error.</p>
<p><code>noeviction</code> moves the problem: when memory is full, Redis returns an error for commands that add data (<a href="https://redis.io/docs/latest/develop/reference/eviction/">Redis eviction docs</a>), so new jobs can't be written. Finished jobs are kept forever by default, so cap them with <code>removeOnComplete</code> and <code>removeOnFail</code> in the scheduler's template (<a href="https://docs.bullmq.io/guide/queues/auto-removal-of-jobs">auto-removal of jobs</a>). The same guide recommends AOF persistence. Many hosted Redis instances don't persist by default, and a restart without persistence loses every scheduler until your code upserts them again.</p>
<h2>Stalled Jobs and Processors That Never Return</h2>
<p>A worker holds a lock on each active job: <code>lockDuration</code> (30 seconds by default) is renewed on a timer at half that interval, and every <code>stalledInterval</code> (30 seconds) a check moves jobs with an expired lock back to waiting and emits <code>stalled</code> (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/interfaces/worker-options.ts">WorkerOptions</a>). The usual cause is a CPU-bound processor: synchronous work that holds the event loop for more than 30 seconds stops the renewal timer, so another worker starts the same job while the first may still be running it (<a href="https://docs.bullmq.io/guide/workers/stalled-jobs">stalled jobs</a>). Move heavy work into a <a href="https://docs.bullmq.io/guide/workers/sandboxed-processors">sandboxed processor</a>, which runs in a separate process (or a worker thread with <code>useWorkerThreads</code>), or break it up so it yields.</p>
<p>The <a href="https://docs.bullmq.io/guide/jobs/stalled">stalled page</a> says a job that stalls more than <code>maxStalledCount</code> times (default 1) fails with "job stalled more than allowable limit". Jobs produced by a scheduler are exempt: since BullMQ 5.58.8 they go back to waiting after every stall, and since 5.78.1 only while their scheduler still exists (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/moveStalledJobsToWait-9.lua#L74-L99">moveStalledJobsToWait-9.lua</a>). A scheduled job that crashes its worker can loop indefinitely. The worker option <code>maxStartedAttempts</code> (BullMQ 5.59.0 and later) caps it: a job started more times than that fails with "job started more than allowable limit" before the processor runs (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L1157-L1169">worker.ts</a>).</p>
<p>The opposite case doesn't stall at all. An async processor waiting on a request or connection that never settles leaves the event loop free, so the lock renews indefinitely and the job stays active. With the default <code>concurrency</code> of 1, that worker takes no other job, including the scheduler's next run. Give the job's own I/O a deadline, for example <code>AbortSignal.timeout()</code>, so a hang becomes an error that BullMQ retries or fails. <a href="https://cronradar.com/blog/cron-job-timeout">Cron job timeouts</a> covers finding what a hung job waits on.</p>
<h2>Retries, Failed Jobs, and Which Events Fire</h2>
<p>Set <code>attempts</code> and <code>backoff</code> in the scheduler template's <code>opts</code>. Without a backoff, retries are immediate, and throwing <code>UnrecoverableError</code> skips the remaining attempts (<a href="https://docs.bullmq.io/guide/retrying-failing-jobs">retrying failing jobs</a>). A job is retried while <code>attemptsMade + 1 &#x3C; attempts</code> (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job.ts#L706-L721">job.ts</a>), which is how the processor below recognizes its last attempt.</p>
<p>The events differ in a way that matters for alerting (<a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts">worker.ts</a>, <a href="https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/moveToFinished-14.lua">moveToFinished-14.lua</a>):</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>Fires when</th>
</tr>
</thead>
<tbody>
<tr>
<td>Worker <code>failed</code></td>
<td>Any attempt throws, including one that will be retried</td>
</tr>
<tr>
<td><code>QueueEvents</code> <code>failed</code></td>
<td>The job lands in the failed set</td>
</tr>
<tr>
<td><code>QueueEvents</code> <code>retries-exhausted</code></td>
<td>The failed job has used all its attempts</td>
</tr>
<tr>
<td>Worker <code>error</code></td>
<td>Connection problems, and failures to schedule a scheduler's next run</td>
</tr>
</tbody>
</table>
<p>Alerting on the worker's <code>failed</code> event pages you for failures a retry would fix; <a href="https://cronradar.com/blog/cron-job-retries">retrying failed cron jobs</a> covers choosing what to alert on. All of these need a running worker: with no worker, or no scheduler, nothing fires.</p>
<h2>Monitoring BullMQ Job Schedulers With CronRadar</h2>
<p>A schedule-aware monitor works from the other direction: it expects each scheduler's run at its scheduled time and alerts when the report is late, failed, or never finishes. There is no CronRadar SDK you can install for Node.js today and no BullMQ integration, so nothing is discovered automatically. Each scheduler reports over HTTP from the worker's processor to its own monitor, keyed by the scheduler ID.</p>
<p>This complete ES module (Node.js 20 or later, BullMQ 6.4) reconciles the schedulers, upserts only changed ones, and reports <code>start</code>, then <code>complete</code> or <code>fail</code>. Reports time out after five seconds and log instead of throwing; the job's own error is always rethrown, so retries behave as before:</p>
<pre><code class="language-javascript">// scheduled-jobs.mjs
import { Queue, Worker, UnrecoverableError } from 'bullmq'

const connection = { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 }
const QUEUE_NAME = 'scheduled'
const MAX_STARTS = 5 // caps re-runs after stalls; must exceed attempts

// One entry per scheduler: five-field cron in UTC, which BullMQ and CronRadar read alike.
const schedules = new Map([
  [
    'nightly-invoice-export',
    { pattern: '30 2 * * *', timeoutMs: 20 * 60_000, run: exportInvoices },
  ],
])

const jobOptions = {
  attempts: 3,
  backoff: { type: 'exponential', delay: 60_000 },
  removeOnComplete: { count: 200 },
  removeOnFail: { count: 1000 },
}

async function exportInvoices(signal) {
  const response = await fetch('https://billing.example.com/api/exports/invoices', {
    method: 'POST',
    signal,
  })
  if (!response.ok) throw new Error(`Invoice export returned HTTP ${response.status}`)
}

const PING_URL = 'https://cron.life/api/v1/ping'

// Monitoring must never break the job: 5-second timeout, errors logged, never thrown.
async function checkIn(key, event, params) {
  const apiKey = process.env.CRONRADAR_API_KEY
  if (!apiKey) return
  const path = event === 'heartbeat' ? '' : `/${event}`
  const url = `${PING_URL}/${encodeURIComponent(key)}${path}?${new URLSearchParams(params)}`
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` },
      signal: AbortSignal.timeout(5000),
    })
    if (!response.ok) console.error(`CronRadar ${event} for ${key}: HTTP ${response.status}`)
  } catch (error) {
    console.error(`CronRadar ${event} for ${key} failed: ${error.message}`)
  }
}

const queue = new Queue(QUEUE_NAME, { connection })
queue.on('error', (error) => console.error('Queue error:', error.message))

// This file owns every scheduler on the queue: remove any it no longer defines.
for (const scheduler of await queue.getJobSchedulers()) {
  if (!schedules.has(scheduler.key)) await queue.removeJobScheduler(scheduler.key)
}

for (const [id, { pattern }] of schedules) {
  const current = await queue.getJobScheduler(id)
  // An upsert replaces the pending run. Skipping unchanged schedulers keeps
  // a restart from dropping a run that an outage left overdue.
  if (current?.pattern === pattern &#x26;&#x26; current.tz === 'UTC') continue
  await queue.upsertJobScheduler(id, { pattern, tz: 'UTC' }, { opts: jobOptions })
}

const worker = new Worker(
  QUEUE_NAME,
  async (job) => {
    const id = job.repeatJobKey
    const definition = schedules.get(id)
    if (!definition) throw new UnrecoverableError(`No scheduler definition for ${job.name}`)

    const params = {
      schedule: definition.pattern,
      gracePeriod: '600',
      run_id: job.id, // repeat:&#x3C;scheduler id>:&#x3C;timestamp>, the same across retries
      attempt: String(job.attemptsMade),
    }
    await checkIn(id, 'start', params)
    try {
      await definition.run(AbortSignal.timeout(definition.timeoutMs))
    } catch (error) {
      // Mirror BullMQ's retry decision so only the last attempt reports a failure.
      const lastAttempt =
        error instanceof UnrecoverableError ||
        job.attemptsMade + 1 >= (job.opts.attempts ?? 1) ||
        job.attemptsStarted >= MAX_STARTS
      if (lastAttempt) {
        const message = `${id} failed on attempt ${job.attemptsMade + 1}: ${error.name}`
        await checkIn(id, 'fail', { ...params, message })
      }
      throw error
    }
    await checkIn(id, 'complete', params)
  },
  { connection, maxStartedAttempts: MAX_STARTS },
)
worker.on('error', (error) => console.error('Worker error:', error.message))

async function shutdown() {
  await worker.close()
  await queue.close()
  process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
</code></pre>
<p>The <code>schedule</code> on each check-in makes the monitor self-registering: the first run creates it, and a deleted monitor comes back on the next run. <code>schedule</code> and <code>gracePeriod</code> apply only when a check-in creates the monitor, so change them later in the monitor's settings. Keep the failure message generic; error details can carry data that shouldn't leave your system. Store <code>CRONRADAR_API_KEY</code> in your secret manager, with one CronRadar application and key per environment.</p>
<h3>What the Monitor Catches</h3>
<table>
<thead>
<tr>
<th>Failure</th>
<th>What BullMQ shows</th>
<th>What the monitor does</th>
</tr>
</thead>
<tbody>
<tr>
<td>No worker running, or Redis unreachable</td>
<td>Nothing; no process emits events</td>
<td>No <code>start</code> by the expected time: <code>warning</code>, <code>critical</code></td>
</tr>
<tr>
<td>Scheduler removed, renamed, or evicted</td>
<td>It's missing from <code>getJobSchedulers()</code></td>
<td>The same missed-run alert</td>
</tr>
<tr>
<td>Last attempt failed</td>
<td>The job is in the failed set</td>
<td>The <code>fail</code> check-in opens an incident immediately</td>
</tr>
<tr>
<td>Processor hangs, or a stall loop is cut off</td>
<td>Job active, or failed by the start cap</td>
<td>A <code>start</code> with no finish: <code>critical</code> as stuck, see below</td>
</tr>
</tbody>
</table>
<p>The overdue check runs once a minute. A monitor turns <code>warning</code> once its expected time passes and becomes <code>critical</code>, with an incident, only when it is past the grace period and at least two minutes late. The 600-second grace here absorbs time a run spends waiting in a busy queue.</p>
<p>Stuck detection needs the <code>start</code> check-in. Every check-in, <code>start</code> included, moves the expected time to the scheduler's next occurrence. If that time plus the grace period passes while the last event was a <code>start</code> with no <code>complete</code> or <code>fail</code>, the monitor goes <code>critical</code> as stuck. The window is per scheduler, not per run: the next run's <code>start</code> resets it, so a hang is caught only when the scheduler then goes silent, which for a daily job is a day later. CronRadar has no per-job maximum-runtime setting; the <code>AbortSignal.timeout</code> inside the job is what turns a hang into a prompt <code>fail</code>.</p>
<p>When you retire a scheduler on purpose, including one that ends through <code>endDate</code> or <code>limit</code>, pause or delete its monitor, or it will report missed runs.</p>
<h3>Schedules CronRadar Can't Read</h3>
<p>CronRadar reads standard five-field cron, evaluated in UTC. It rejects a seconds field, <code>?</code>, <code>L</code>, <code>#</code>, <code>7</code> for Sunday, <code>@</code>-shortcuts, and intervals, so translate BullMQ schedules before sending them:</p>
<ul>
<li><strong>Six fields with a fixed second</strong>, such as <code>0 15 3 * * *</code>: drop the seconds and send <code>15 3 * * *</code>.</li>
<li><strong>A non-UTC <code>tz</code>:</strong> send the UTC equivalent. If the zone observes daylight saving time, use the earlier of its two UTC times and a grace period of at least an hour plus the job's usual duration.</li>
<li><strong><code>every</code> of a minute or more:</strong> its runs are anchored to the first run, not the clock, so they won't match a cron expression. Switch to an equivalent pattern, such as <code>*/5 * * * *</code> for <code>every: 300_000</code>, or keep <code>every</code> and set the monitor's grace period to at least the interval.</li>
<li><strong>Sub-minute schedules</strong>, such as <code>every: 10_000</code> or <code>*/10 * * * * *</code>: monitor <code>* * * * *</code> and send a plain heartbeat at most once per clock minute after a successful run. A heartbeat detects missed runs but not duration or stuck runs.</li>
</ul>
<pre><code class="language-javascript">let lastHeartbeatMinute = -1

// In the processor of a sub-minute scheduler, after the work succeeds:
const minute = Math.floor(Date.now() / 60_000)
if (minute !== lastHeartbeatMinute) {
  lastHeartbeatMinute = minute
  await checkIn('queue-poller', 'heartbeat', { schedule: '* * * * *' })
}
</code></pre>
<p>Check-ins are limited to 60 requests a minute and 1,000 an hour per API key: that heartbeat uses up to 60 an hour per worker process, and per-minute start and complete reporting uses 120.</p>
<h2>Frequently Asked Questions</h2>
<h3>What replaced BullMQ repeatable jobs?</h3>
<p>Job Schedulers, added in BullMQ 5.16.0 and managed with <code>upsertJobScheduler</code>, <code>getJobSchedulers</code>, and <code>removeJobScheduler</code>. BullMQ 6.0.0 removed the <code>repeat</code> option on <code>queue.add</code> and the methods that managed it, so code using them must migrate before upgrading.</p>
<h3>Does BullMQ run missed repeatable jobs after downtime?</h3>
<p>Not all of them. The overdue job runs once, late, and the schedule continues from the current time, so the runs missed in between are lost. For a <code>pattern</code> scheduler, an upsert at startup can drop even that late run.</p>
<h3>Why is my BullMQ job stalled?</h3>
<p>Its worker didn't renew the job's lock in time, usually because synchronous CPU work blocked the event loop for more than 30 seconds, or the process died mid-job. The job goes back to waiting and runs again. Use sandboxed processors for CPU-heavy work and close workers on <code>SIGTERM</code>.</p>
<h3>How do I stop duplicate repeatable jobs in BullMQ?</h3>
<p>Use <code>upsertJobScheduler</code> with a stable ID; the same ID updates the existing scheduler instead of adding one. Renaming an ID leaves the old scheduler running in Redis, so compare <code>getJobSchedulers()</code> with your definitions on deploy and remove the extras.</p>
<h3>Does CronRadar auto-discover BullMQ job schedulers?</h3>
<p>No. There is no installable CronRadar SDK for Node.js today and no BullMQ auto-discovery. Each scheduler reports with HTTP check-ins from its processor, and the first check-in that carries a schedule creates its monitor.</p>
]]></content:encoded>
      <pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate>
      <author>noreply@cronradar.com (Dan Ochoa)</author>
      <category>bullmq repeatable jobs</category>
      <category>bullmq job scheduler</category>
      <category>upsertJobScheduler</category>
      <category>bullmq cron</category>
      <category>bullmq stalled jobs</category>
    </item>
  </channel>
</rss>