# How to Monitor WordPress Cron Jobs (WP-Cron)

By Dan Ochoa · Published 2025-12-04 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/wordpress-cron-monitoring

> WP-Cron only runs when someone loads a page, so backups and renewals can run late. Trigger it from real cron and get alerts when WordPress jobs fail.

**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:

1. A visitor loads a page.
2. WordPress checks whether any scheduled events are due.
3. If some are, it sends a background request to `wp-cron.php`.
4. 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](https://developer.wordpress.org/plugins/cron/).

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!":

```php
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

```php
// 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:

```bash
*/5 * * * * cd /var/www/html && /usr/local/bin/wp cron event run --due-now --quiet
```

Replace `/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:

```bash
*/5 * * * * curl --silent --max-time 30 "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
```

Every 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](https://cronradar.com/blog/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`:

```bash
# 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-now
```

A 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`:

```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](https://cronradar.com/blog/cron-dead-mans-switch) pattern.

Cron treats `%` as a special character, so put the commands in a script rather than in the crontab line:

```bash
#!/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"
```

```bash
*/5 * * * * /usr/local/bin/wp-cron-run.sh >> /var/log/wp-cron-run.log 2>&1
```

The 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:

```php
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
<?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:

```php
// 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:

```php
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](https://cronradar.com/php) and the [PHP integration guide](https://docs.cronradar.com/integrations/php) 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:

```php
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:

```bash
wp cron event list --fields=hook,recurrence,next_run_gmt
```

Some 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](https://actionscheduler.org/wp-cli/).

## Putting It All Together

For a WooCommerce site:

1. **Disable the traffic trigger** with `DISABLE_WP_CRON` in `wp-config.php`.
2. **Run WP-Cron every 5 minutes** from server cron with WP-CLI.
3. **Report a heartbeat** from that cron job, so CronRadar alerts if it stops.
4. **Wrap critical jobs** such as exports and renewals with start, complete, and fail reports.
5. **Check Action Scheduler** for pending and failed actions.
6. **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_CRON` is set and nothing replaced it.** `wp config get DISABLE_WP_CRON` shows the value. When it is `true`, 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](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/cron.php)). 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 blocks `wp-cron.php`.
- **Every visit is served from cache.** See [Why WP-Cron Runs Late](#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](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/class-wp-site-health.php)). `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](https://github.com/wp-cli/cron-command#wp-cron-test)).

## 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.
