How to Monitor WordPress Cron Jobs (WP-Cron)
Updated
Contents
To monitor WordPress cron, trigger WP-Cron from a real server cron job, then have that job and your critical callbacks report to an external monitor that alerts when a report is late or a failure arrives. WP-Cron only checks for due events when a page loads, so a quiet site, a full-page cache, or a blocked loopback request can delay scheduled work without any error.
This guide explains why WP-Cron runs late, how to replace its traffic-based trigger, and how to find out when scheduled work stops.
Why WP-Cron Runs Late#
WP-Cron is not a daemon. On each page load, WordPress checks its list of scheduled events:
- A visitor loads a page.
- WordPress checks whether any scheduled events are due.
- If some are, it sends a background request to
wp-cron.php. - That request runs the due events.
No page loads means no cron. WordPress's handbook gives the example directly: "Scheduling errors could occur if you schedule a task for 2:00PM and no page loads occur until 5:00PM." Due events run at the next opportunity, which on a quiet site can be hours later. See the WordPress cron handbook.
That delay shows up as:
- Late backups: a backup plugin's nightly job waits for the first visitor in the morning.
- Late renewals and emails: WooCommerce and other plugins queue work that depends on WP-Cron running.
- "Missed schedule" posts: a scheduled post stays unpublished past its time.
- Stale maintenance: update checks and cleanup run whenever traffic happens to arrive.
Page caching can make it worse. If a cache or CDN serves pages without running PHP, those visits never reach WordPress, and never trigger WP-Cron.
Step 1: Disable the Traffic-Based Trigger#
Add this to wp-config.php, above the line that says "That's all, stop editing!":
define( 'DISABLE_WP_CRON', true );WordPress stops spawning cron on page loads. Scheduled events still exist; Step 2 runs them from the server instead. Only disable it once that server cron job is in place.
Other constants#
// Use if your server blocks loopback requests to wp-cron.php
define( 'ALTERNATE_WP_CRON', true );
// Lock timeout for cron runs, in seconds (default: 60)
define( 'WP_CRON_LOCK_TIMEOUT', 120 );ALTERNATE_WP_CRON redirects the visitor's request to run cron instead of making a background request. Use it only if spawning fails and you can't add a server cron job.
Step 2: Run WP-Cron from Server Cron#
With WP-CLI, the server runs due events directly, without an HTTP request:
*/5 * * * * cd /var/www/html && /usr/local/bin/wp cron event run --due-now --quietReplace /var/www/html with your WordPress path, and run it as the user that owns the site files. If you can't use WP-CLI, request wp-cron.php instead:
*/5 * * * * curl --silent --max-time 30 "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1Every 5 or 15 minutes suits most sites; use 5 when subscriptions, scheduled posts, or email queues need to run close to their time. Some managed hosts already run WP-Cron from the server, so check your host's documentation before adding a second trigger. If the cron job itself doesn't run, the cron job not running checklist covers users, paths, and logs.
Step 3: Check for Overdue Events#
Overdue events are listed with a next run of now:
# All scheduled events
wp cron event list
# Events that are due or overdue
wp cron event list --next_run_relative=now --fields=hook,next_run_gmt,next_run_relative
# Run everything that is due
wp cron event run --due-nowA few now events right before the server cron runs is normal. A long list that never clears means nothing is running them.
You can surface the same check in wp-admin from a small plugin or your theme's functions.php:
function myplugin_overdue_cron_count() {
$overdue = 0;
foreach ( _get_cron_array() as $timestamp => $hooks ) {
if ( $timestamp < time() ) {
$overdue += count( $hooks );
}
}
return $overdue;
}
add_action( 'admin_notices', function () {
$overdue = myplugin_overdue_cron_count();
if ( $overdue > 5 ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html( sprintf( '%d cron events are overdue. Check the server cron job.', $overdue ) )
);
}
} );Why Plugin-Based Monitoring Isn't Enough#
WP Crontrol and similar plugins let you view, edit, and run cron events from the dashboard. They are useful for debugging, but they only work while WordPress works. If the site is down, the database is unreachable, or PHP hits a fatal error, they can't tell you.
| Approach | What it catches | What it misses |
|---|---|---|
| WP Crontrol | Overdue events, when someone looks | Failures nobody is looking at; a site that is down |
| Server cron logs | Whether the trigger ran | Whether each event's work completed |
| Uptime monitoring | Whether the site responds | Individual cron jobs that fail or stop |
For backups, payments, and scheduled content, use a check that runs outside WordPress.
Step 4: Monitor the Server Cron Job#
Have the server cron job report a heartbeat each time WP-CLI finishes without an error. If the heartbeats stop because cron stopped, the server is down, or WordPress can't boot, the monitor alerts. This is the dead man's switch pattern.
Cron treats % as a special character, so put the commands in a script rather than in the crontab line:
#!/bin/sh
# /usr/local/bin/wp-cron-run.sh
# Loads CRONRADAR_API_KEY from a file readable only by the cron user.
# A missing file only disables the report; WP-Cron still runs.
set -a
[ -r /etc/cronradar/wordpress.env ] && . /etc/cronradar/wordpress.env
set +a
cd /var/www/html || exit 1
/usr/local/bin/wp cron event run --due-now --quiet
status=$?
if [ "$status" -eq 0 ]; then
curl --fail --silent --show-error --max-time 5 \
--user "$CRONRADAR_API_KEY:" \
"https://cron.life/api/v1/ping/wp-cron-runner?schedule=*/5%20*%20*%20*%20*" || true
fi
exit "$status"*/5 * * * * /usr/local/bin/wp-cron-run.sh >> /var/log/wp-cron-run.log 2>&1The first report creates the wp-cron-runner monitor from its schedule. The five-second timeout and || true keep a monitoring problem from failing the run, and the script still exits with WP-CLI's own status. Create a separate CronRadar application and key for each site environment.
Step 5: Report Critical Callbacks#
The heartbeat proves WP-Cron is being triggered. It doesn't prove your nightly export or backup finished. For those, report from inside WordPress.
WordPress cron timestamps are in UTC, and CronRadar evaluates schedules in UTC, so the same expression describes both. The CronRadar PHP SDK is temporarily unavailable on Packagist, so this uses the HTTP API through WordPress's own HTTP functions.
Define the key in wp-config.php, read from the server's environment so it isn't stored in the code:
define( 'CRONRADAR_API_KEY', getenv( 'CRONRADAR_API_KEY' ) ?: '' );WP-CLI started by the script in Step 4 inherits the variable. PHP-FPM may not pass environment variables to web requests; if your host can't, set the key in wp-config.php directly and keep that file out of version control.
Then add a small plugin. Its requests wait for a response with a five-second timeout, because a non-blocking request can be dropped when the cron process exits:
<?php
/**
* Plugin Name: CronRadar Reporting
* Description: Reports WP-Cron heartbeats and critical job results to CronRadar.
*/
function cronradar_report( $monitor_key, $event = '', $query = array() ) {
if ( ! defined( 'CRONRADAR_API_KEY' ) || '' === CRONRADAR_API_KEY ) {
return;
}
$url = 'https://cron.life/api/v1/ping/' . rawurlencode( $monitor_key );
if ( '' !== $event ) {
$url .= '/' . $event;
}
if ( $query ) {
$url .= '?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 );
}
$response = wp_remote_post( $url, array(
'timeout' => 5,
'headers' => array(
'Authorization' => 'Basic ' . base64_encode( CRONRADAR_API_KEY . ':' ),
),
) );
// wp_remote_post returns an error object instead of throwing.
if ( is_wp_error( $response ) ) {
error_log( 'CronRadar report failed for ' . $monitor_key . ': ' . $response->get_error_message() );
}
}
// Wrap a job you own: start, then complete or fail, and rethrow the job's error.
add_action( 'myplugin_nightly_export', function () {
cronradar_report( 'wp-nightly-export', 'start', array(
'schedule' => '0 3 * * *',
'gracePeriod' => 600, // allow for the server cron interval
) );
try {
myplugin_run_export();
} catch ( Throwable $e ) {
cronradar_report( 'wp-nightly-export', 'fail', array(
'message' => 'Nightly export threw ' . get_class( $e ),
) );
throw $e;
}
cronradar_report( 'wp-nightly-export', 'complete' );
} );Schedule myplugin_nightly_export for 03:00 UTC with wp_schedule_event() as usual. A failure report opens an incident immediately, and a start without a completion or a run that never arrives is caught against the schedule. The failure message carries the exception class only; never send error details, logs, or customer data. If your function signals failure by returning a WP_Error rather than throwing, check the result and report fail in that case too.
For a hook another plugin owns, add a callback at the latest priority that sends a heartbeat. It runs only after the plugin's own callbacks on that hook have returned:
// Use the hook name and schedule shown by `wp cron event list`.
add_action( 'some_plugin_daily_hook', function () {
cronradar_report( 'wp-some-plugin-daily', '', array(
'schedule' => '0 4 * * *',
'gracePeriod' => 600,
) );
}, PHP_INT_MAX );This proves the hook ran to the end, not that the plugin finished its work; some backup plugins split one backup across several cron runs.
If you can't edit the server crontab#
On hosts that run WP-Cron for you, a WordPress-scheduled heartbeat shows that WP-Cron itself keeps running. Add this to the same plugin. The activation hook works because it is in a plugin file, not a theme. The first run is aligned to the top of an hour so the heartbeat matches its CronRadar schedule:
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'cronradar_wp_cron_heartbeat' ) ) {
$next_hour = ( intdiv( time(), HOUR_IN_SECONDS ) + 1 ) * HOUR_IN_SECONDS;
wp_schedule_event( $next_hour, 'hourly', 'cronradar_wp_cron_heartbeat' );
}
} );
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'cronradar_wp_cron_heartbeat' );
} );
add_action( 'cronradar_wp_cron_heartbeat', function () {
cronradar_report( 'wp-cron-heartbeat', '', array(
'schedule' => '0 * * * *',
'gracePeriod' => 1800, // the host's trigger can run well after the hour
) );
} );Choose where alerts go in CronRadar: email, Slack, webhook, Discord, or PagerDuty. Then let a real run report, and confirm a test alert reaches whoever will act on it. The PHP monitoring overview and the PHP integration guide cover the HTTP setup and the SDK's availability.
Optional: A Health Endpoint#
For an uptime monitor that polls a URL, a small REST endpoint can report overdue events. Protect it with a token, because hook names describe your site's plugins:
add_action( 'rest_api_init', function () {
register_rest_route( 'cron-monitor/v1', '/health', array(
'methods' => 'GET',
'callback' => 'cron_monitor_health',
'permission_callback' => function ( WP_REST_Request $request ) {
$expected = defined( 'CRON_MONITOR_TOKEN' ) ? (string) CRON_MONITOR_TOKEN : '';
$given = (string) $request->get_header( 'X-Monitor-Token' );
return '' !== $expected && hash_equals( $expected, $given );
},
) );
} );
function cron_monitor_health() {
$overdue = 0;
foreach ( _get_cron_array() as $timestamp => $hooks ) {
if ( $timestamp < time() ) {
$overdue += count( $hooks );
}
}
return new WP_REST_Response( array(
'status' => $overdue > 10 ? 'critical' : ( $overdue > 3 ? 'warning' : 'healthy' ),
'overdue_count' => $overdue,
'wp_cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON,
'checked_at' => gmdate( 'c' ),
), 200 );
}Define CRON_MONITOR_TOKEN in wp-config.php and send it in the X-Monitor-Token header from the monitoring service. The endpoint is at https://example.com/wp-json/cron-monitor/v1/health.
Which Cron Hooks to Watch#
Start with the hooks whose failure costs money or data. List what your site actually schedules:
wp cron event list --fields=hook,recurrence,next_run_gmtSome WordPress core hooks to recognize:
| Hook | What it does |
|---|---|
publish_future_post |
Publishes a scheduled post at its time |
wp_version_check |
Checks for WordPress core updates |
wp_update_plugins |
Checks for plugin updates |
wp_scheduled_delete |
Empties old items from the trash |
Backup, security, and e-commerce plugins register their own hooks; their names appear in the list above.
WooCommerce and Action Scheduler#
WooCommerce bundles Action Scheduler, which many WooCommerce extensions use to queue work. Its default runner is driven by WP-Cron, so it inherits WP-Cron's timing. Check WooCommerce → Status → Scheduled Actions for:
- Pending actions that are far past their scheduled time
- A growing number of failed actions
- Actions stuck "in progress"
For large queues, Action Scheduler recommends WP-CLI over its WP-Cron runner: wp action-scheduler run. See the Action Scheduler WP-CLI guide.
Putting It All Together#
For a WooCommerce site:
- Disable the traffic trigger with
DISABLE_WP_CRONinwp-config.php. - Run WP-Cron every 5 minutes from server cron with WP-CLI.
- Report a heartbeat from that cron job, so CronRadar alerts if it stops.
- Wrap critical jobs such as exports and renewals with start, complete, and fail reports.
- Check Action Scheduler for pending and failed actions.
- Set alerts for:
- Missed heartbeats, when cron stopped entirely
- Failure reports from a specific job
- The health endpoint reporting
critical, if you added it
WP-Cron Not Running at All#
Late events point to missing traffic. Events that never run point to one of these causes:
DISABLE_WP_CRONis set and nothing replaced it.wp config get DISABLE_WP_CRONshows the value. When it istrue, only a server cron job (Step 2) or a host-provided trigger runs events.- The loopback request fails. WordPress starts cron with a POST to its own
wp-cron.php, sent with a 0.01-second timeout and without waiting for the response, so a failure is never reported (spawn_cron()source). Typical blockers are a server that can't reach its own public hostname (common in containers and behind NAT), a password-protected staging site (the request sends no credentials), and a firewall or security plugin that blockswp-cron.php. - Every visit is served from cache. See Why WP-Cron Runs Late.
Two built-in checks narrow it down. Tools > Site Health reports "Your site could not complete a loopback request" when the loopback fails, and "A scheduled event is late" or "A scheduled event has failed" when events fall behind (Site Health source). wp cron test tries to spawn WP-Cron over HTTP and warns on a non-200 response; it exits with an error when DISABLE_WP_CRON is set, so it only applies to the traffic-based trigger (WP-CLI cron command).
Troubleshooting#
Cron runs but tasks still fail. Check the PHP error log. A callback that hits a fatal error stops that cron run without telling WP-Cron anything.
Scheduled posts show "Missed schedule." WP-Cron wasn't running when the post was due. Confirm the server cron job runs and that page caching isn't hiding traffic from WordPress.
Long jobs seem to block others. Backup plugins can run for many minutes. While one cron run holds WP-Cron's lock, the next run doesn't start until the lock is released or WP_CRON_LOCK_TIMEOUT passes, so other due events wait. Schedule heavy jobs at quiet times.
WooCommerce actions stay pending. Check Scheduled Actions. If they never clear, nothing is triggering Action Scheduler; confirm WP-Cron runs, or run wp action-scheduler run from server cron.
Frequently Asked Questions#
Does WP-Cron run if nobody visits the site?#
No. WP-Cron checks for due events only when a page loads, so a site without traffic runs nothing until the next visit. Trigger it from server cron with wp cron event run --due-now so it runs on schedule.
How do I check if WP-Cron is working?#
Run wp cron event list --next_run_relative=now to see events that are due. If the list keeps growing, nothing is running them. To be told when cron stops rather than finding out later, have the server cron job report a heartbeat to an external monitor.
Should I disable WP-Cron?#
Yes, once a server cron job runs it on a schedule. DISABLE_WP_CRON stops WordPress from spawning cron on page loads; scheduled events keep running through the server trigger. Disabling it without that trigger stops scheduled events entirely.
How often should server cron run wp-cron?#
Every 5 or 15 minutes suits most sites. An event can run up to one interval late, so use 5 minutes when scheduled posts, subscriptions, or email queues need to run close to their time.
What does "Missed schedule" mean on a WordPress post?#
WordPress didn't run the publish_future_post event at the post's scheduled time, usually because WP-Cron wasn't triggered. Moving WP-Cron to a server cron job prevents most cases.
Put this guide to work
Connect your PHP job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The PHP SDK is temporarily unavailable on Packagist. Use manual HTTP reporting below. This setup does not discover jobs automatically.