wp-cron.php is a PHP script in your WordPress root that runs due scheduled events, and WordPress triggers it with a background HTTP request to itself at the end of a normal page load. It is not a system cron job, nothing runs on a timer, and if nobody visits the site nothing fires.
The common advice is to disable it for performance. That advice is now mostly out of date, and the real reasons to disable it are different ones.
How the file gets called
Core hooks wp_cron() onto init, and since WordPress 6.9 that handler registers _wp_cron() on the shutdown action rather than running during the request (wp-includes/cron.php). The work happens after the page has already been sent.
_wp_cron() reads the queue, and if anything is due it calls spawn_cron(). That function sends the trigger:
$cron_request = apply_filters( 'cron_request', array(
'url' => $cron_url,
'key' => $doing_wp_cron,
'args' => array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),
), $doing_wp_cron );
$result = wp_remote_post( $cron_request['url'], $cron_request['args'] );
Three things in that array matter. The timeout is one hundredth of a second, the request is non-blocking, and SSL verification is off by default for the loopback.
So the visitor who happens to trigger cron waits 10 milliseconds at most, in a shutdown handler, after their page has rendered. The "wp-cron.php slows your site down" claim describes much older behaviour.
Why a missed job is usually a loopback problem
blocking is false, which means wp_remote_post() returns without reading a response. Nothing checks whether the request arrived.
That is the single most common cause of "my scheduled job never runs". If the server cannot reach its own site_url(), because of a firewall rule, HTTP basic auth, a split-horizon DNS entry, or a local hosts file that only the browser knows about, the POST goes nowhere and WordPress records no error at all.
The fix is to test the loopback directly rather than reading the event list. From a shell on the site:
curl -sS -o /dev/null -w '%{http_code}\n' "$(wp option get siteurl)/wp-cron.php?doing_wp_cron=1"
A 200 means the loopback works and the problem is elsewhere. A connection error, a 401, or a redirect to another host is your answer.
Note the ?doing_wp_cron= parameter in that URL. Without it, wp-cron.php tries to claim the lock itself, which is fine, but with a junk key it will exit silently at the key comparison.
The 60-second floor nobody mentions
spawn_cron() refuses to run if the lock is younger than WP_CRON_LOCK_TIMEOUT, which wp-includes/default-constants.php defines as MINUTE_IN_SECONDS:
// Don't run if another process is currently running it or more than once every 60 sec.
if ( $lock + WP_CRON_LOCK_TIMEOUT > $gmt_time ) {
return false;
}
Scheduling an event every 30 seconds therefore does not get you 30-second resolution. Core will not spawn more than once a minute, so your custom schedule is quietly rounded up.
The lock is the doing_cron transient, and wp-cron.php reads it back with a direct SELECT against the options table rather than through the transient cache. That is deliberate, because a cached read would let two concurrent workers both believe they hold it.
There is also a clock-skew guard. A lock more than ten minutes in the future is treated as zero and discarded.
A job that fatals does not retry
This is the behaviour that surprises people most, and it is visible in the main loop of wp-cron.php. For each due event, core reschedules the recurring instance, then unschedules the current one, and only then fires the hook:
if ( $schedule ) {
$result = wp_reschedule_event( $timestamp, $schedule, $hook, $v['args'], true );
}
$result = wp_unschedule_event( $timestamp, $hook, $v['args'], true );
do_action_ref_array( $hook, $v['args'] );
The event is removed from the queue before the callback is given a chance to fail. If that callback hits a fatal error or exhausts memory, the run is lost and nothing marks it as failed.
For a recurring event that is survivable, because the next occurrence was already written. For a one-off event scheduled with wp_schedule_single_event(), the task is simply gone.
There is no retry, no dead-letter queue, and no admin notice. If a nightly export quietly produced nothing last Tuesday, this is the mechanism that let it happen silently.
Reschedule and unschedule errors are the exception: since 6.1 both are written to the error log and fire cron_reschedule_event_error and cron_unschedule_event_error. Those lines go to debug.log when WordPress debug logging is on, which is worth turning on before you go hunting.
Two guards at the top of the file
Before any of that, wp-cron.php exits early in two cases:
if ( ! empty( $_POST ) || defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) ) {
die();
}
A POST request with an actual body dies immediately. If you are testing with curl -X POST -d something, you are measuring the die(), not the cron run, which is a confusing five minutes if you have not read the file.
The file also calls fastcgi_finish_request() or litespeed_finish_request() where available, and sets ignore_user_abort( true ). The HTTP response closes first and the work continues after it, which is why a 10 millisecond timeout on the caller's side is enough.
DISABLE_WP_CRON does less than its name suggests
define( 'DISABLE_WP_CRON', true );
That constant is checked in _wp_cron(), so it stops WordPress spawning the loopback on page loads. It does not protect the file.
The header comment in wp-cron.php says so plainly: defining the constant and calling the file directly "are mutually exclusive and the latter does not rely on the former to work".
Anyone on the internet can still request https://example.com/wp-cron.php and make your site do work. That is the actual performance argument for taking control of cron, and it applies whether or not you set the constant.
If you set it, you must replace it with something. A system cron entry every minute is the standard answer:
* * * * * cd /var/www/html && wp cron event run --due-now --quiet
Using WP-CLI rather than curl-ing the file keeps it off the public HTTP path entirely. One caveat: wp-cron.php calls wp_raise_memory_limit( 'cron' ), and a WP-CLI invocation never passes through that file, so a job that needs more memory may behave differently under the two triggers.
ALTERNATE_WP_CRON, and why it is a last resort
define( 'ALTERNATE_WP_CRON', true );
This exists for servers where the loopback cannot be made to work. Instead of a background request, core redirects the current visitor to the same URL with ?doing_wp_cron=<timestamp> appended, then runs the queue inline in that request.
Read that again, because it is the cost. A real visitor gets a 302 and lands on a URL with a query string on it, which pollutes analytics and can miss your page cache.
It also only engages on plain GET requests, and bails out for AJAX and XML-RPC:
if ( 'GET' !== $_SERVER['REQUEST_METHOD'] || defined( 'DOING_AJAX' ) || defined( 'XMLRPC_REQUEST' ) ) {
return false;
}
On a site behind full-page caching, that is a thin slice of traffic. Fix the loopback instead if you possibly can.
What to check, in order
- Is anything due?
wp cron event list --due-now. - Does the loopback resolve? The
curlabove, run from the server, not your laptop. - Is the lock stuck?
wp transient get doing_cron. A value within the last 60 seconds blocks a new spawn. - Does the callback actually work?
wp cron event run <hook>runs it in the foreground, where a fatal error is visible instead of silent.
Step 4 is the one that catches the failures the queue cannot show you. Our longer walkthrough of testing a WordPress cron job covers separating the three layers properly.
Testing cron changes without guessing on production
Every one of these changes is a wp-config.php edit plus a server-level cron entry, and the feedback loop on a live site is an hour of waiting to find out you got it wrong.
A disposable copy is the cheaper way round. On a SandyWP sandbox you get SSH and WP-CLI on the real install, so you can set DISABLE_WP_CRON, run wp cron event run --due-now by hand, and watch what the callback does before you touch anything that matters.
Two other things help when the job is failing rather than missing. Debug mode gives you debug.log in the browser, which is where the reschedule errors and any fatal in the callback land.
The email log catches anything the job sends through wp_mail(), so a digest email that never arrived in production is visible immediately rather than lost to a missing sendmail.
If the callback is dying rather than misfiring, the white screen debugging guide is the faster path.
When WP-Cron is fine as it is
Not every site needs this. A site with steady traffic, no sub-hourly schedules, and no job that matters to the minute runs perfectly well on the default loopback, and swapping it for a system cron adds a thing that can break with no benefit.
Disable it when one of three things is true: the site is quiet enough that jobs genuinely miss their windows, something needs to run at a predictable time, or you want wp-cron.php off the public request path. Otherwise, leave it alone.
