Skip to content
System nominalUTC --:--:--Articles 041

WP-Cron Fires When a Visitor Arrives, Not When the Clock Says So

WP-Cron is a request-triggered scheduler. What wp-cron.php does on a quiet site versus a busy one, and where the pattern stops being enough.

Martin Kiel · @mkiel

WordPress internals

Topic
WP
Section
WordPress Internals
Published
29 SEP 2026
Reading time
8 MIN
Words
1942W
Technical cover graphic for WP-Cron Fires When a Visitor Arrives, Not When the Clock Says So
Generated schematic — WP series, REV 2.6.

A support request arrives on a Tuesday: "the daily stock sync hasn't run since Friday, and two customers got their reminder email twice." The site is a small WooCommerce store. Traffic is sparse, a few hundred sessions a day, and the sync is scheduled for 03:00. Nothing is broken in the code that does the syncing. What is broken is the assumption underneath it, which is that 03:00 means something to WordPress.

WP-Cron is not a scheduler. It is a queue that gets nudged. WordPress notices it has work due at the moment a page request is shutting down, and it has no way to notice anything while no request is happening at all. Overnight, on a store that sees nobody, nothing is due and nothing runs. On a busy store the same mechanism runs so often that the jobs pile into each other, and that is where the duplicate emails come from.

Both symptoms come from the same design decision, and they want opposite short-term fixes. One needs a trigger that does not care whether anyone is browsing. The other needs the work to stop being triggered at all.

A page view is what starts the scheduler

Every scheduled event lives in a single row in wp_options, under the option name cron. It is an autoloaded serialised array keyed by Unix timestamp, and each timestamp holds the hooks due at that moment along with their arguments and recurrence. Because it is autoloaded, it is in memory on every request whether or not anything is due.

Once the request has finished producing its response, WordPress checks that array. The helper that decides is wp_get_ready_cron_jobs(), added in WordPress 5.1. It reads the array through _get_cron_array() and returns only the timestamps that have already passed, so a typical request walks the array and comes back with nothing.

// wp-includes/cron.php, condensed from wp_get_ready_cron_jobs()
$crons    = _get_cron_array();
$gmt_time = microtime( true );
$results  = array();

foreach ( $crons as $timestamp => $cronhooks ) {
    if ( $timestamp > $gmt_time ) {
        break;
    }
    $results[ $timestamp ] = $cronhooks;
}

return $results;

The loopback is the part that surprises people. spawn_cron() sends a non-blocking POST to wp-cron.php with a lock value in the query string, and the lock is a transient named doing_cron. The default lock window is one minute, set by the WP_CRON_LOCK_TIMEOUT constant, so a second request within that minute will not spawn a second runner even if it also finds due work. That is the only protection against two runners starting at once, and it says nothing about how long the first one takes.

Two things about the loopback are worth knowing. Current core does not fall back to running the work inside the visitor's request if the loopback fails: the spawn returns false, the event stays due, and the next request tries again. On a host that blocks loopback requests, the work never runs and nothing says so. And ALTERNATE_WP_CRON is the opposite trade, running the queue inside the triggering request.

On WordPress 6.9 and newer the spawning is attached to the shutdown action rather than wp_loaded, so the HTML can be flushed before WordPress waits on the loopback. On older versions the check happened earlier and a slow loopback request was time the visitor spent waiting. If your TTFB is worse on the specific requests that trigger cron, this is the line to look at.

A missed schedule is a traffic report, not a failure

WordPress shows "Missed schedule" on a post whose publish time has passed without the publish_future_post event firing. Nothing about the post is wrong. The event was queued, the clock passed it, and no request arrived afterwards to notice. The label reads like a bug in WordPress and behaves like a description of your traffic pattern. Post it at 22:00 on a site with no night traffic and it will appear at the next visit, whenever that is.

Where to look first

Before changing anything, confirm which failure you have. Run wp cron event list --fields=hook,next_run_relative,recurrence and look at the relative times. Everything overdue by hours on a quiet site is the traffic problem. Everything overdue by seconds on a busy site is the contention problem.

The same test answers the duplicate-email question, because a hook that runs twice and a hook that runs late look identical from the outside. WP-CLI ships a cron test that reports whether the loopback works at all, which is worth running on any host that blocks outgoing requests from PHP to itself:

wp cron test
wp cron event list --fields=hook,next_run_gmt,recurrence --format=table
wp cron event run --due-now

On a host that blocks loopback requests, wp cron test fails, and that failure is worth taking literally: the jobs stay queued and nothing runs them. If you find that on a production host, moving to a system cron trigger is not an optimisation, it is the repair.

DISABLE_WP_CRON moves the trigger, not the work

The pattern most hosts recommend is to stop letting visitors trigger cron at all, and call wp-cron.php from a real scheduler instead. Add the constant to wp-config.php, above the line that requires wp-settings.php:

define( 'DISABLE_WP_CRON', true );

With that defined, the function that WordPress hooks into the request returns immediately, so no page view spawns a runner and no page view runs the work inline. The file itself still works when something calls it directly. On cPanel, the Cron Jobs screen takes a command and only accepts the system's own PHP binary, so the line names it explicitly and passes the result to a shell that stays quiet unless something fails:

*/5 * * * * /usr/local/bin/php /home/example/public_html/wp-cron.php >/dev/null 2>&1

That line is fine for a single site. On multisite, or on a box where you would rather not run a PHP file directly against a web-writable document root, the WP-CLI form is more honest about what it is doing, because it goes through the same bootstrap as every other command and takes the site explicitly:

*/5 * * * * /usr/local/bin/wp --path=/home/example/public_html --url=https://example.com cron event run --due-now >/dev/null 2>&1

Five minutes is a common interval and it is also a decision. Any event scheduled more frequently than your external trigger is a lie you have told yourself, because the array will accumulate due timestamps and run them in one burst when the trigger finally arrives. I would rather set the interval to something I can defend and write the schedules to match.

Overlapping runs are how one job becomes two

Two mechanisms exist to stop a hook running twice, and both are weaker than they look. The first is the doing_cron lock, which is held while a runner is active. It is set before the runner starts and released at the end, so a job that outlives the runner's process is not protected by it at all. If PHP hits max_execution_time halfway through a sync, the process dies, the lock is eventually cleared by its own timeout, and nothing in that sequence knows the sync was incomplete.

The second is the event array itself. A recurring event is rescheduled and then unscheduled as the runner picks it up, and both writes go through the options API. Take a runner that is assumed dead while it is merely slow: the external trigger fires again, wp_get_ready_cron_jobs() sees the same timestamp still present, and the hook runs a second time. That is how a reminder email goes out twice, and it is more likely on exactly the jobs that matter, because long jobs are the ones most likely to overlap with the next trigger.

The fix that holds is to make the job itself refuse a second start. A short-lived lock stored in the object cache or as an expiring transient, keyed to the specific order or the specific sync, turns "please do not run twice" into a check the job performs on itself:

add_action( 'my_store_stock_sync', function () {
    $lock = 'my_store_stock_sync_lock';

    // Not atomic on every object cache backend, but it is a real check
    // rather than a hope.
    if ( get_transient( $lock ) ) {
        return;
    }

    set_transient( $lock, time(), 15 * MINUTE_IN_SECONDS );

    try {
        my_store_run_stock_sync();
    } finally {
        delete_transient( $lock );
    }
} );

It is not a distributed lock and I would not use it to guard money. It is enough to stop the common case, which is one slow run overlapping the next trigger.

Where the whole approach stops working

Everything above still assumes that running late is acceptable. For a stock sync, a sitemap rebuild or a nightly report, late is fine. For an order timeout that must release reserved stock after fifteen minutes, or a subscription renewal that must attempt a charge on a specific date, late is the bug. A better trigger interval does not fix it, because the constraint is the mechanism: WP-Cron runs your work in a PHP process shared with whatever else is due, on a clock that only advances when traffic arrives, and a job that fails is simply gone.

The wrong place to look

Raising WP_CRON_LOCK_TIMEOUT or shortening the system cron interval does not make a job run on time. The first widens the window in which a slow job is invisible, and the second multiplies the chances of overlapping runs. Neither adds a retry.

Durable scheduling is a different shape. Jobs are rows in a table rather than entries in an option, a worker claims them with an atomic update, failures are retried with a backoff, and the queue is a list you can query instead of an array you have to decode. Action Scheduler is the one most WordPress sites already have, because WooCommerce ships it. Its function names follow the WP-Cron ones with an as_ prefix and actions in place of events, and the four-argument form below is the whole API for a one-shot job:

as_schedule_single_action(
    time() + 15 * MINUTE_IN_SECONDS,
    'my_store_release_stock',
    array( 'order_id' => $order_id ),
    'my-store'
);

as_has_scheduled_action( 'my_store_release_stock', array( 'order_id' => $order_id ), 'my-store' );

Two details about Action Scheduler are easy to get wrong. It initialises itself on init at priority 1, so calling its functions before that point can write to the wrong data store; from a plugin, hook at or after init. And the queue still needs a runner. Its own runner is itself a WP-Cron event, which puts you back where you started if that is the only consumer, so on a store where these jobs matter, run the queue from the system cron instead. The options that matter are the batch size, how many batches to process, and the group, and the version scope here is Action Scheduler 4.x:

*/1 * * * * /usr/local/bin/wp --path=/home/example/public_html action-scheduler run --group=my-store --batch-size=25 --batches=4 >/dev/null 2>&1

What to do on Monday

  1. Run wp cron event list and classify every overdue event as either traffic starvation or overlap. The relative times tell you which.
  2. If the site is quiet, define DISABLE_WP_CRON and add one system cron entry. Run wp cron test first, while the constant is still unset, because the command errors out once you have defined it.
  3. Set the external interval to something you will keep, then fix any plugin schedule that is shorter than it.
  4. Add a per-job lock to the two or three hooks whose duplicate execution would cost money or trust. Ignore the rest.
  5. Move anything with a deadline onto Action Scheduler, and run its queue from the system cron with a batch size and a time limit.
  6. Alert on the queue, not on the schedule. A pending action older than an hour is a measurable failure; a missed schedule is a symptom you read about later.

Martin Kiel · @mkiel

WordPress internals

Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.

Continue

Related in WordPress Internals