WP-Cron vs System Cron: Making WordPress Scheduled Tasks Reliable

by Francis Rozange | Oct 2, 2026 | Performance

WordPress has no clock. When a post is scheduled for 2 p.m., no process waits until 2 p.m. to publish it. WordPress checks, on each visit, whether a task is due. If nobody visits the site before 5 p.m., the post waits until 5 p.m.: that is the official documentation’s own example, applied here to a post.

This mechanism, WP-Cron, carries far more than scheduled posts: update checks, automatic updates, emptying the trash and expired transients, many plugins’ backups, a store’s subscription renewals. Since WordPress 7.1, it also carries the daily cleanup of personal data requests.

This guide explains how WP-Cron really fires, why it misses on small sites and weighs on large ones, how to replace it with a real system cron, how WooCommerce’s job queue fits in, and how to debug and monitor scheduled tasks.

How WP-Cron really fires

A check on every request, a lock against duplicates

On every request that reaches PHP, WordPress looks at the list of scheduled tasks, stored in a single autoloaded database option. If a task is due, it starts execution, but a lock, set by the WP_CRON_LOCK_TIMEOUT constant to sixty seconds by default, blocks a new spawn while the previous one is running, for up to those sixty seconds.

The loopback request to wp-cron.php

So the visitor does not have to wait, WordPress does not run the tasks within their request. It sends an HTTP request to its own wp-cron.php file, with a 0.01-second timeout and in non-blocking mode, then keeps rendering the page. That second process runs the due tasks.

This loopback assumes the server can call itself. A firewall, HTTP basic authentication, a DNS or certificate problem is enough to block it, and tasks stop running.

What depends on WP-Cron

WordPress core schedules a set of tasks itself: checking for core, plugin and theme updates twice a day, daily deletion of expired transients, old auto-drafts and trash, the weekly Site Health check, and since WordPress 7.1 the daily cleanup of personal data requests. Automatic updates depend on it directly.

Plugins add their own tasks: backups, email sends, syncs, cleanups. A WP-Cron that stops running therefore means a site that no longer updates, backs up or cleans itself, with no visible error message.

What the page cache changes

A page served by a page cache or a CDN never reaches PHP, and therefore never reaches WP-Cron. A very well cached site can thus receive thousands of visits without a single one triggering scheduled tasks.

The real case: the “non-blocking” request that blocked

The story of moving WP-Cron in WordPress 6.9, in the fall of 2025, shows what visit-triggered scheduling costs. It is documented in core commits, a performance team note and an issue on WordPress’s HTTP library.

A 0.01-second timeout that lasted a second

The loopback request is supposed to never make the visitor wait. Yet in August 2023, a developer reported on the repository of the Requests library, which WordPress uses for its HTTP calls, that non-blocking mode did not always work: his script stayed suspended until the response came back. In October 2026, that issue is still open.

Core’s code now acknowledges it in its comments: the request function does not always respect the timeout and blocking parameters, and a 0.01-second timeout may end up taking one second. As long as the spawn happened before the page was sent, that delay held back the first byte the visitor received.

The move

On October 12, 2025, Peter Wilson, a core committer, landed the change prepared by Weston Ruter: the WP-Cron spawn moved to the very end of the request, once the page has been sent. The commit message sums up the problem: the loopback request was meant to be non-blocking, but was not always, and increased response time.

On November 18, Weston Ruter, from the performance team, presented the change in the WordPress 6.9 guide: a possible one-second reduction in time to first byte, for requests that spawn WP-Cron. We found no independent measurement of that gain.

A regression before release

The move broke one particular setup: sites using the ALTERNATE_WP_CRON constant, a fallback method that redirects the visitor to run tasks within their own request. On November 27, 2025, five days before release, Weston Ruter restored the old behavior for those sites only. WordPress 6.9 shipped on December 2, 2025 with both changes.

What ALTERNATE_WP_CRON does

This fallback mode only applies to standard read requests, excluding AJAX and XML-RPC. Instead of sending a loopback request, WordPress redirects the visitor to the same address with a spawn parameter, then runs the tasks in the original request, the one the browser has just left. The documentation presents it as a fix for scheduled posts that fail to publish. It is usually turned on when the loopback request is blocked. The visitor pays the price here too, with an extra redirect on every spawn.

What to take away

Visit-triggered scheduling is paid for by a real visitor: a 0.01-second request can cost a second. WordPress 6.9 moved that cost after the page is sent, without removing it: the spawn still runs inside a visitor’s PHP process, and the HTTP library problem is not fixed. Fallback modes such as ALTERNATE_WP_CRON remain fragile special cases.

With a system cron and WP-Cron disabled on visits, none of these questions arise: WordPress no longer even tries to spawn tasks during visitors’ requests. If your site’s response time concerns you, our guide to Core Web Vitals fixes details the other levers.

Why WP-Cron misses on small sites and weighs on large ones

Low traffic: the task waits for a visitor

On a little-visited site, or one almost entirely served by the cache, tasks wait for the next request that reaches PHP. A scheduled post looks late, a nightly backup starts in the morning, a subscription renewal waits for a customer to log in.

High traffic: a busy process, a single queue

On a very busy site, WP-Cron spawns often, and each spawn ties up a PHP process on the server for as long as the tasks run. Tasks run one after another: a long task delays all the following ones, and if it exceeds the lock timeout, another process can take over.

Memory and execution time

Since WordPress 6.3, wp-cron.php raises PHP’s memory limit to the value meant for heavy tasks, 256 MB by default. That increase does not apply to tasks run by WP-CLI or by a mechanism that bypasses this file. On the command line, however, PHP sets no maximum execution time by default, which suits long tasks better.

The catch-ups that never happen

A missed recurring task is not replayed. If a daily task could not run for three days, it runs only once when things recover, then WordPress calculates the next due date. For a cleanup, that does not matter. For a process that counts on every run, executions are lost.

A steady pulse of light driving a row of glass gears

Scheduling properly: intervals and wp_schedule_event

Core intervals and custom intervals

WordPress provides four intervals: hourly, twice daily, daily and weekly. A plugin can add more with the cron_schedules filter, giving a duration in seconds and a label.

add_filter( 'cron_schedules', function ( $schedules ) {
	$schedules['every_ten_minutes'] = array(
		'interval' => 600,
		'display'  => 'Every ten minutes',
	);
	return $schedules;
} );

A very short interval guarantees nothing: the one-minute lock and the pace of visits, or of the system cron, set the real frequency.

Avoiding duplicates and cleaning up

Before scheduling a recurring task, a plugin must check that it does not already exist, or it will pile up on every load. The documentation offers this pattern, and reminds you to remove the task when the plugin is deactivated.

if ( ! wp_next_scheduled( 'my_plugin_hourly_job' ) ) {
	wp_schedule_event( time(), 'hourly', 'my_plugin_hourly_job' );
}

register_deactivation_hook( __FILE__, function () {
	wp_clear_scheduled_hook( 'my_plugin_hourly_job' );
} );

For a single event, WordPress ignores a new scheduling of the same event within ten minutes of an existing one, unless its arguments differ. A task that “will not schedule” often comes from this.

A task that runs too long

A long task poses a particular problem. If it exceeds the lock duration, another spawn can take over while it is still running; core’s code then stops the first process’s loop. Heavy processing therefore benefits from being split into small batches, each scheduled separately, or handed to a queue such as Action Scheduler, built for that work.

Switching to a real system cron

DISABLE_WP_CRON: what the constant does, and does not do

The DISABLE_WP_CRON constant, in wp-config.php, removes the visit-triggered spawn. It does not disable tasks: wp-cron.php keeps running them when called. The file’s own comment says so: the two are independent.

define( 'DISABLE_WP_CRON', true );

Setting this constant without putting another trigger in place is the most common mistake: nothing runs anymore, silently, no scheduled posts, no automatic updates, no renewals.

Calling wp-cron.php by URL, or running WP-CLI

The documentation suggests calling wp-cron.php from the system scheduler. A crontab line that does so every fifteen minutes looks like this.

*/15 * * * * wget -q -O - https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

The other method runs due tasks with WP-CLI, without going through HTTP. The SpinupWP server control panel wraps it in flock, which prevents two simultaneous runs.

*/5 * * * * cd /path/to/wordpress && flock -n ~/.wp_cron.lock /usr/local/bin/wp cron event run --due-now --quiet

Each method has its strengths. The URL call keeps core’s lock and the memory increase meant for tasks, but depends on the network and the site’s authentication.

WP-CLI avoids all loopback problems and has no default time limit on the command line, but the current stable release does not respect core’s lock: hence the value of flock, or of using a single method. Do not run WP-CLI as root: its documentation strongly advises against it. Our selection of essential WP-CLI commands details its setup and uses.

Checking that the system cron runs

Once it is in place, list the tasks and their next due date. A task whose due date passed long ago signals a trigger that is not working.

wp cron event list --fields=hook,next_run_relative

Multisite and managed hosts

On Multisite, each site has its own task list: you need one call per site, for example by looping over the site list with WP-CLI. Many managed hosts offer their own trigger, each at its own pace. Kinsta runs a server cron on every site every fifteen minutes, and you then disable WP-Cron yourself.

WP Engine offers an Alternate Cron you switch on in its portal, which checks for due tasks every minute. Pantheon runs them hourly, and WordPress VIP runs its own runner every thirty seconds. Ask your host what it does before adding your own cron.

Two triggers in parallel do not make the site more reliable: they sometimes run the same tasks twice. Each host also has its constraints: Pantheon requires external calls to leave the spawn parameter empty, and WP Engine warns that its alternate cron does not work behind custom HTTP authentication. Pick a single method, and document it.

Action Scheduler, WooCommerce’s job queue

How it relies on WP-Cron

WooCommerce and many plugins hand their background jobs to Action Scheduler, a queue that stores each job in its own tables: renewals, emails, webhooks, analytics imports. Its documentation states it does not depend on WP-Cron, yet WP-Cron is what starts it by default, at most once a minute, in batches of twenty-five jobs for thirty seconds.

If WP-Cron stops, the queue only runs when someone loads the admin, which on a store nobody logs into means almost never. The WooCommerce Subscriptions documentation even treats several jobs more than a day overdue as a possible sign of a WP-Cron problem.

Running it with WP-CLI on a busy store

For a store that processes many jobs, the Action Scheduler documentation recommends WP-CLI, far better suited than the default runner. A dedicated cron line runs the queue regularly.

wp action-scheduler run --batch-size=100

A small official plugin then lets you disable the default runner, with a clear warning: without WP-CLI or another method, no jobs will be processed anymore.

WooCommerce subscriptions

WooCommerce Subscriptions relies on Action Scheduler for recurring payments, trial ends, payment retries and expirations. Its documentation states that, without WP-Cron, the queue can then only be processed by admin requests. A health check, in the WooCommerce status screens, summarizes overdue subscription jobs. For a subscription store, a dedicated system cron is essential.

Retention and growing tables

Completed or canceled jobs are purged after thirty-one days; since version 4.0, shipped with WooCommerce 11.0, failed jobs are purged after three months. A queue that does not run, or that a plugin floods, still inflates the database, as our guide to WordPress database optimization recounts.

Posts showing “Missed schedule”

What scheduled publishing does

Scheduling a post creates a single event at the planned publication date. When it runs, WordPress publishes the post if it is still scheduled and its time has come; if the event fires too early, it reschedules itself. If WP-Cron does not run, the posts list shows a missed schedule message, flagged in red.

Catching up without triggering everything

To publish late posts, WP-CLI runs the due tasks. Beware of a trap: without the --due-now option, the command applied to a task name runs every event with that name, including those planned for the future. Scheduled publishing protects itself, since the event reschedules if its time has not come, but other tasks would run early.

wp cron event run --due-now

Plugins such as MWW Scheduled Post Trigger or Missed Scheduled Posts Publisher republish missed posts. These plugins depend on visits themselves and only fix posts. The first one’s listing presents it as a stopgap, while you find out why the scheduler is not working.

Debugging with WP Crontrol

Reading the task list

WP Crontrol, free, maintained by John Blackbourn, the author of Query Monitor, and installed on more than 300,000 sites as of October 2026, shows every scheduled task with its arguments, interval, callbacks and next due date. It lets you run, pause, edit or delete them, and flags those with no callback attached or that missed their due date.

Deleting a task that keeps coming back is pointless: its plugin recreates it. Pause its name instead. A task with no callback usually comes from a deleted plugin and can be removed. WP Crontrol does not yet keep a run history; for a log, Advanced Cron Manager offers one in its paid version.

Spawn errors

When tasks do not run at all, the cause is often the loopback request: DNS, firewall, HTTP authentication, certificate, missing wp-cron.php file or a fatal PHP error. The wp cron test command checks that spawning works, as long as WP-Cron is not disabled, and WP Crontrol’s help lists these causes one by one.

WP Crontrol PHP and URL tasks

WP Crontrol also lets you create tasks that run PHP code or call an address. That is powerful, and therefore sensitive: in March 2024, a flaw scored 8.1 out of 10 allowed remote code execution provided another flaw already existed on the site or the database had been compromised, fixed in version 1.16.2. Since version 1.18, the CRONTROL_DISALLOW_PHP_EVENTS constant disables PHP tasks. Use it on sites that do not need them.

A security eye

The wp-cron.php file runs on every spawn, which makes it a target. In 2025, Wordfence described a fake security plugin accompanied by a modified wp-cron.php that re-created and reactivated it on the next visit if it was deleted, and one variant of which used a task scheduled every minute to contact its command server. An unknown task on an unusual interval deserves investigation, and wp core verify-checksums checks that core files have not been modified.

Monitoring cron health

Site Health, WP-CLI and Action Scheduler

Site Health reports a late or failed task. Without DISABLE_WP_CRON, a task more than five minutes late is considered failed; with the constant, the thresholds move to fifteen minutes and one hour, to allow for a system cron’s pace. This test only looks at the WP-Cron list, not at the Action Scheduler queue, which shows its own admin warning as soon as a job is more than a day overdue.

An external heartbeat

No internal check can see a system cron that has stopped running altogether. A monitoring service such as Healthchecks.io expects a signal on every run and alerts when it does not arrive. All it takes is adding a call at the end of the cron line, for example && curl -fsS -m 10 --retry 5 -o /dev/null followed by the address the service provides.

Healthchecks.io offers a free plan with twenty checks at the time of writing, and can also be self-hosted. Cronitor, another service of the same kind, offers a free plan with five monitors. Our WordPress maintenance checklist lists the other checks to run regularly.

Summary table

Method Trigger Limits Best for
Default WP-Cron Visits that reach PHP Delays, fragile loopback, cache Small sites with no timing stakes
ALTERNATE_WP_CRON Visitor redirect Special case, fragile Temporary troubleshooting
System cron by URL Server scheduler Depends on network and authentication Most sites
System cron with WP-CLI Server scheduler Lock to add, SSH access Stores, busy sites
Host runner Host service Imposed pace Sites on managed hosting

Frequently asked questions

Should WP-Cron always be disabled?

No, only if you replace it with a system cron or your host does. A small site with no urgent tasks can keep the default behavior. Disabling without replacing is the worst option.

Which interval should the system cron use?

Five to fifteen minutes suits most sites. A store with subscriptions or many background jobs benefits from one or five minutes, with a lock to avoid simultaneous runs.

Can wp-cron.php be called every minute?

Yes, but each call loads WordPress, even when no task is due. Kinsta sets five minutes as the minimum interval for its custom crons. One minute is justified for a very active store; for most sites, five to fifteen minutes are enough.

WP-CLI or a URL call?

WP-CLI if you have SSH access and the site is protected by authentication or a firewall. The URL call is simpler to set up on shared hosting, and keeps core’s lock.

Why do my scheduled posts still miss?

First check that a trigger really exists: DISABLE_WP_CRON without a system cron stops everything. Then, if WP-Cron is not disabled, test spawning with wp cron test, which errors as soon as DISABLE_WP_CRON is set. With a system cron, check the task list with wp cron event list instead, and look in WP Crontrol for whether the publishing task exists and when it is due.

Does Action Scheduler need WP-Cron?

Not in theory, but WP-Cron is what starts it by default. If you disable WP-Cron, keep a system cron that calls wp-cron.php or run the queue with WP-CLI, otherwise Action Scheduler jobs stop along with everything else.

Did WordPress 6.9 solve the problem?

It removed the visitor’s wait before the page is shown, not the dependency on visits nor the work inside a visitor’s PHP process. A system cron remains the reliable solution.

Conclusion

WP-Cron is a clever compromise: it makes scheduled tasks work on any hosting, with no configuration. That compromise is paid for in delays on quiet sites, load on busy ones, and fragility as soon as the server can no longer call itself.

For a site that matters, the recipe fits in three lines: turn off the visit-triggered spawn, set up a system cron, by URL or WP-CLI, and monitor that it runs with an external signal. On a store, add a line for Action Scheduler.

Sources


LaFactory designs, builds and maintains WordPress and WooCommerce sites, and develops its own plugins. Talk to us about your WordPress project.

Francis Rozange

Former section editor at Libération, he runs LaFactory, an international web agency since 1996.

Cart