DEV Community

Cover image for WP-Cron vs system cron: what to check when WordPress scheduled tasks start missing
AumCreate
AumCreate

Posted on

WP-Cron vs system cron: what to check when WordPress scheduled tasks start missing

A renewal email lands four hours after the invoice goes out. A nightly import stops for six days and nobody notices until the numbers are wrong. Nothing in the logs says cron failed, because nothing failed in a way that logs.

WP-Cron is a due-check, not a scheduler. On each page request WordPress looks at the cron option, finds events whose timestamp has passed, and fires a non-blocking loopback request to wp-cron.php to run them. No traffic, no runs. That is the whole failure mode, and it covers most "the plugin stopped working" reports.

Confirm the event is late

wp cron event list --fields=hook,next_run_gmt,recurrence,args
Enter fullscreen mode Exit fullscreen mode

next_run_gmt is UTC. Compare it with date -u, not with the clock in your site's timezone settings. An event with a next_run_gmt hours in the past is overdue. An event that is missing from the list was never scheduled, which is a different bug: plugin inactive, scheduling hooked to admin-only code, or something called wp_clear_scheduled_hook.

Look at the spawn lock

Before spawning, WordPress writes a doing_cron transient and holds it for WP_CRON_LOCK_TIMEOUT seconds, 60 by default. That lock is why a dead run doesn't cause every page load to start another one, and it is also why a dead run silences the next minute of attempts.

wp transient get doing_cron
Enter fullscreen mode Exit fullscreen mode

A value that persists across several minutes means something is still holding it. Clearing it is safe once you know no run is in flight:

wp transient delete doing_cron
Enter fullscreen mode Exit fullscreen mode

Check that spawning is enabled

wp config get DISABLE_WP_CRON
wp config get ALTERNATE_WP_CRON
Enter fullscreen mode Exit fullscreen mode

DISABLE_WP_CRON set to true means page loads never trigger anything. That is correct when a system cron exists, and a silent outage when someone copied the setting from a tutorial and the crontab line never got added.

Test the loopback request

The spawn is an HTTP request from the server to itself. Basic auth, a maintenance plugin, a WAF rule on wp-cron.php, or a hosts-file mismatch kills it while the site still looks fine to visitors.

wp eval 'var_dump( wp_remote_retrieve_response_code( wp_remote_get( add_query_arg( "nocache", time(), site_url( "wp-cron.php" ) ), array( "timeout" => 5 ) ) ) );'
Enter fullscreen mode Exit fullscreen mode

You want 200. This call does run any due events, which is harmless and a little useful. Tools → Site Health → Info has a loopback test that tells you the same thing without typing.

Find the other scheduler

crontab -l
sudo -u www-data crontab -l
Enter fullscreen mode Exit fullscreen mode

Hosting panels add entries under their own user. Two crons calling wp cron event run --due-now is survivable, since an event only fires once per due timestamp, but two crons with different paths or one running as root against a different wp-config.php produces behaviour that looks random.

Switch

In wp-config.php, above the "stop editing" line:

define( 'DISABLE_WP_CRON', true );
Enter fullscreen mode Exit fullscreen mode

In the crontab of the user that owns the files, not root:

*/5 * * * * flock -n /var/lock/example-wp-cron.lock -c 'cd /var/www/example.com/current && /usr/local/bin/wp cron event run --due-now --quiet'
Enter fullscreen mode Exit fullscreen mode

What each part is doing:

  • cd into the install so wp finds wp-config.php without a --path flag.
  • flock -n returns immediately when the previous run is still going. Without it a slow run overlaps the next one and two processes write the same options.
  • Run as the site user. --allow-root works and leaves root-owned files in wp-content, which breaks uploads and plugin updates later.
  • Five minutes is a reasonable default. Each invocation boots WordPress, so a small site has a floor of roughly one run per minute.
  • A time-critical hook can get its own tighter line (wp cron event run my_hook) without changing the general interval.

Without shell access, a panel cron or an external cron service hitting a URL works:

https://example.com/wp-cron.php?doing_wp_cron=1699999999
Enter fullscreen mode Exit fullscreen mode

Include a changing value in the query string or a CDN may serve a cached response indefinitely. Test it once and confirm a known event moved in wp cron event list; whether a direct hit to wp-cron.php still executes events while DISABLE_WP_CRON is on is not something to assume across versions and hosts.

Shared hosting without SSH, a panel cron nobody documented, a site that changed hands with no notes: in any of those the switch above takes longer than ten minutes, because it turns into a conversation with the host. Most support desks will add a crontab line if you hand them the exact command and the path. Moving to a host that gives you shell access is the other clean route, and it is a reasonable moment to have the site rebuilt properly: AumCreate does a fixed-scope WordPress deployment onto hosting you already own — template installed, every page rewritten with your copy, a preview link, one round of changes, then live, with a one-page note on how to edit content — for a one-time $399 (what the handover covers). Domain and hosting stay in your name and are billed by those providers, and there is no annual fee from us. It is a build, so if the cron tasks are the actual problem, ask for the scheduled events and who owns them to be written into the handover.

What breaks quietly afterwards

Queues that are not WP-Cron. WooCommerce and a long list of plugins put background work into Action Scheduler, which keeps its own tables and its own runner. wp cron event run --due-now does not drain that queue. Check the scheduled-actions screen after the switch, and add a deterministic trigger if you want one:

*/5 * * * * cd /var/www/example.com/current && /usr/local/bin/wp action-scheduler run --quiet
Enter fullscreen mode Exit fullscreen mode

Long events blocking the batch. wp cron event run --due-now runs due events in a single process. One event stuck on a remote API eats the run and everything behind it waits for the next tick. Batch heavy work — 50 rows at a time, store a cursor, let the next invocation pick up the rest.

Make a missed run visible

Record the last successful run, and take a lock so a slow run cannot double-process:

add_action( 'myplugin_sync', function () {
    global $wpdb;

    $got = $wpdb->get_var(
        $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', 'myplugin_sync', 5 )
    );

    if ( '1' !== (string) $got ) {
        return; // another run holds the lock
    }

    try {
        // work here
        update_option( 'myplugin_last_sync', time(), false );
    } finally {
        $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', 'myplugin_sync' ) );
    }
} );
Enter fullscreen mode Exit fullscreen mode

GET_LOCK is per database connection, so it releases when the process dies, including a hard kill. That makes it a better fit than an option flag that survives a crash and blocks the next ten runs.

Expose the age of that timestamp to whatever already watches the site:

add_action( 'rest_api_init', function () {
    register_rest_route( 'health/v1', '/cron', array(
        'methods'             => 'GET',
        'permission_callback' => '__return_true',
        'callback'            => function () {
            $last = (int) get_option( 'myplugin_last_sync', 0 );
            $age  = $last ? time() - $last : PHP_INT_MAX;

            return new WP_REST_Response(
                array( 'last_sync_age_seconds' => $age ),
                $age < 3600 ? 200 : 500
            );
        },
    ) );
} );
Enter fullscreen mode Exit fullscreen mode

A monitor expecting 200 from that endpoint every five minutes catches the failure wp cron event list never shows: a cron entry that exists, runs on schedule, and does nothing useful. Pair it with an attempt counter and a cap, so a task that fails permanently lands somewhere readable instead of retrying forever with no record.

The switch is ten minutes of work. The part that needs thinking is which events deserve a system cron at all: anything that moves money, sends mail, or expires access belongs there; a weekly transient cleanup does not.

Top comments (0)