WordPress runs its scheduled work (backups, scheduled posts, update
checks, plugin clean-up jobs) through WP-Cron, and WP-Cron only runs
when someone loads a page. This guide explains how WP-Cron works, why
it stops without an error, how to move it onto a real system cron, and
three ways to get alerted when it stops: the Cronheart plugin, one
curl, or the PHP SDK.
What WP-Cron is
WordPress keeps its own list of scheduled events in the
cron option in the database. No daemon watches that list.
At the end of every page load WordPress checks it, and if an event is
due it fires a non-blocking request to its own wp-cron.php,
which runs the due callbacks. It is a pseudo-cron: between requests,
nothing runs.
That design lets WordPress schedule work on shared hosting where nobody can edit a crontab. The price is timing. As the WordPress cron documentation points out, a task scheduled for 2:00 PM waits for the next page load, even if nobody visits until 5:00 PM.
Why WP-Cron stops without an error
In every case the site still answers HTTPS with a 200, so an uptime monitor stays green. What catches all four is the absence of a check-in: a heartbeat that WP-Cron sends on schedule and an outside service expects.
Run WP-Cron from a real system cron
On a site where scheduled work matters, take WP-Cron off page loads.
Switch the page-load trigger off in wp-config.php, above
the comment that says to stop editing:
define( 'DISABLE_WP_CRON', true );
Then let the system scheduler request wp-cron.php on a
fixed cadence. Every five minutes keeps the delay short:
*/5 * * * * curl -fsS -o /dev/null https://example.com/wp-cron.php
The
WordPress
guide to the system scheduler makes the same request with
wget. With WP-CLI installed,
wp cron event run --due-now --path=/var/www/html runs the
due events inside the cron job's own process instead, so there is no
HTTP request to block.
Monitor WP-Cron with the Cronheart plugin
The official
Cronheart plugin
turns WP-Cron into a dead man's switch. Install it from
Plugins → Add New by searching for “Cronheart”, or with
composer require cronheart/wp. It needs WordPress 6.0 and
PHP 8.2 or newer.
Site heartbeat
Create a monitor that expects a ping every five minutes and copy its
UUID. Give it to the plugin as a constant in wp-config.php,
which keeps it out of the database:
define( 'CRONHEART_HEARTBEAT_UUID', '<monitor-uuid>' );
Or paste it under Settings → Cronheart. The plugin schedules its own five-minute WP-Cron event whose only job is to ping. When those pings stop, WP-Cron has stopped, whichever of the causes above is to blame, and the alert fans out to your channels.
The heartbeat can only fire as often as WP-Cron runs. With page-load WP-Cron, a gap in traffic longer than the grace window is a missed heartbeat, and that alert is the finding: move WP-Cron onto a system cron. With a system cron, run it at least every five minutes, or give the heartbeat monitor an interval that matches your crontab.
Per-event monitoring
The heartbeat proves WP-Cron runs. It does not prove your nightly backup finished. Since version 0.4.0 the plugin's Settings → Cronheart Events screen lists the recurring WP-Cron events on the site. For each one, pick one of your monitors from a dropdown, or click Auto-create & assign to create an interval monitor from the event's own schedule, named after the hook. From then on every run of that event reports start, success or fail.
The screen reaches your account through a cronheart.com
API token, saved under Settings → Cronheart or defined as
CRONHEART_API_TOKEN in wp-config.php. API
access comes with the Starter plan and up, and without a token the
screen is read-only.
On any plan you can map events in code instead. Register the hook from
a plugin or mu-plugin at plugins_loaded or earlier:
add_action( 'plugins_loaded', function () {
cronheart_monitor( 'my_nightly_report', '<monitor-uuid>' );
}, 1 );
A CRONHEART_EVENT_MY_NIGHTLY_REPORT_UUID
constant in wp-config.php does the same without code: the
hook name in capitals, with anything that is not a letter or digit
turned into an underscore. A constant always wins, and the events
screen then shows the event as set in wp-config.php. When
a callback dies with a PHP fatal error, the fail ping carries the error
summary, so the alert already names the cause. A monitoring outage
never breaks WP-Cron: the plugin turns every network error into a
logged warning. Source is on
GitHub.
Monitor a system cron with curl
No plugin, or only a crontab to work with? Chain a heartbeat onto the
job that triggers WP-Cron. Keep the ping URL off the command line:
anyone who has it can mark the job healthy, and every local user can
read the command lines of running processes. Set it as a variable at
the top of your own crontab (crontab -e, a file only you
and root can read) and hand it to curl on stdin:
PING_URL=https://cronheart.com/ping/<uuid>
*/5 * * * * curl -fsS -o /dev/null https://example.com/wp-cron.php && echo "url = $PING_URL" | curl -fsS -m 10 --retry 5 -o /dev/null -K -
echo is a shell builtin, so the URL never
lands on a command line, and -K - makes curl
read its options, the URL included, from stdin. Give the monitor the
same five-minute interval as the crontab line.
This ping proves the system cron fired and WordPress
answered wp-cron.php. It does not prove each event
finished: on PHP-FPM and LiteSpeed, wp-cron.php sends its
response before it runs the events. For the jobs that matter, add the
plugin's per-event monitoring.
Monitor custom PHP jobs with the SDK
Scheduled PHP that runs outside WP-Cron, such as an import script in the
crontab or a Composer-managed worker next to the site, can report its
own runs with the
cron-monitor/php-sdk
package on PHP 8.2 or newer:
composer require cron-monitor/php-sdk
Bracket the job with start, success and
fail:
use CronMonitor\Client\CronMonitorClient;
$client = CronMonitorClient::create();
$uuid = '<monitor-uuid>';
$client->start($uuid);
try {
run_the_import();
$client->success($uuid);
} catch (\Throwable $e) {
$client->fail($uuid, $e->getMessage());
throw $e;
}
The client never throws on a network or HTTP error, so a
monitoring outage cannot break the job. Inside a WordPress plugin or
theme, use cronheart_monitor() instead: the Cronheart
plugin bundles its own copy of the SDK, and that copy may move to
another namespace in a later release. Source is on
GitHub.
Other stacks
The ping endpoint is plain HTTP, so anything that can make a request
works. The guide to
monitoring cron jobs covers crontab, systemd timers, Symfony
Scheduler, Laravel and Node.js with the
cronheart
npm package. The docs list the
start, success and fail actions
and every alert channel.
Best practices
- Keep UUIDs in
wp-config.php. A UUID saved on the settings screen lives in the database, and a database copied to staging brings it along. Staging then pings the production monitor and can hide a real outage. Define the constant per environment, as an empty string where nothing should ping. - Start with the heartbeat. It is one constant and it catches every cause of a stalled WP-Cron. Add per-event monitors for the jobs whose failure costs you something.
- Treat the ping URL as a credential. The UUID is all it takes to mark a job healthy. Keep it out of public repos and screenshots, and rotate it from the dashboard if it leaks.
- Test the alert path. Send a test alert from the dashboard so you know the message lands before a real incident.
Start watching WP-Cron
Free for 20 monitors, no credit card. One plugin and you're covered.