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

CauseWhat happens
No visitorsA quiet site gets no page loads, so due events wait. A nightly backup can slip for days before anyone looks.
Full-page cacheVisits answered by a CDN, Varnish or a static-HTML cache never start WordPress, so they never check the schedule.
Blocked loopbackThe trigger is an HTTP request from the server to itself. A firewall, HTTP basic auth on a staging site, or DNS that resolves the site elsewhere drops it, and no event runs. Tools → Site Health reports a failed loopback request.
Cron switched offDISABLE_WP_CRON turns the page-load trigger off. If the system cron meant to replace it is missing, lost in a server move or never added, nothing runs at all.

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.