WP-Cron and Scheduled Tasks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WordPress needs to do work at given times: publish scheduled posts, check for updates, empty the trash, delete expired transients and run the weekly Site Health check. Plugins add their own jobs, such as the stock synchronisation of the Lumen Press catalogue. WordPress cannot rely on the operating system’s scheduler, because many hosts do not give access to it. It ships its own scheduler, WP-Cron, which keeps a list of events in the database and runs the due ones when the site receives a request. This page explains how that works, why it runs late on quiet sites, how to drive it from a real system scheduler, how a plugin schedules and cleans up its events, and when a job queue such as Action Scheduler is the better tool.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0 and Action Scheduler 4.2.0, and
checked on 2026-10-28. WordPress 7.2 (in beta, due in December 2026) does not
change WP-Cron so far. The last change to how WP-Cron is triggered was in 6.9, described below. Every WP-CLI result on
this page comes from a fresh 7.1.3 installation with the lumen-catalog plugin. The server address is the local test
host lumenpress.test. Production examples use lumenpress.example.
How WP-Cron works
WP-Cron borrows the name of the Unix cron daemon but works differently. Nothing runs in the background. Each request
that loads WordPress checks whether an event is due, and if one is, it starts a separate request to wp-cron.php that
runs the due events.
| Aspect | System cron (Unix cron, systemd timers, Kubernetes CronJob) |
WP-Cron |
|---|---|---|
What triggers it |
A daemon with a clock |
A request to the site (front end, admin, REST, AJAX, WP-CLI) |
Timing |
On time, to the minute |
At the first request after the due time. On a quiet site that can be hours late |
Missed runs |
Lost: a run that did not happen at its time is not retried |
Kept: due events run at the next trigger, however late |
Schedule format |
Calendar fields ( |
An interval in seconds from a first run ( |
Where jobs are stored |
Crontab files |
The |
Who can add jobs |
The server administrator |
Any plugin or theme, from PHP |
The sequence for a request that finds a due event:
-
Core hooks
wp_cron()toinit. Since 6.9,wp_cron()defers the real work,_wp_cron(), to theshutdownaction, so the page is sent to the browser before WordPress starts the cron request. Before 6.9 the check ran onwp_loadedand added its time to the Time To First Byte. -
_wp_cron()returns at once if the request is forwp-cron.phpitself or ifDISABLE_WP_CRONis true. Otherwise it reads the ready events and, if the first one is due, callsspawn_cron(). -
spawn_cron()reads the lock, the transientdoing_cron. If a cron run started less thanWP_CRON_LOCK_TIMEOUTseconds ago (default 60), it does nothing. Otherwise it stores the current time with microseconds indoing_cronand sends a loopback request: aPOSTto its ownsite_url( 'wp-cron.php' )with?doing_wp_cron=<lock value>, a timeout of 0.01 seconds andblockingfalse, so the visitor’s request does not wait for the jobs. -
wp-cron.phpcallsignore_user_abort( true ), closes the connection withfastcgi_finish_request()when PHP-FPM provides it, definesDOING_CRON, loads WordPress and raises the memory limit toWP_MAX_MEMORY_LIMIT(wp_raise_memory_limit( 'cron' )). -
It checks that the lock in the URL matches the stored lock, then loops over the due events in time order. For each event it first reschedules a recurring event, then unschedules the current occurrence, and only then fires the hook with
do_action_ref_array( $hook, $args ). After each event it rereads the lock and stops if another process has taken it over. At the end it deletesdoing_cron.
On the test site, an event scheduled 20 seconds in the future and a request made a minute later produced these two
lines in the Apache access log. The loopback POST is logged first because it finishes before the visitor’s GET
completes its shutdown phase:
172.25.0.3 - - [28/Oct/2026:10:41:57 +0000] "POST /wp-cron.php?doing_wp_cron=1793184117.5809481143951416015625 HTTP/1.1" 200 285 "-" "WordPress/7.1.3; ..."
172.25.0.3 - - [28/Oct/2026:10:41:57 +0000] "GET / HTTP/1.1" 200 70026 "-" "curl/8.14.1"
Consequences of this design:
-
Any request that loads WordPress can trigger it, including admin screens, REST and AJAX calls, and WP-CLI commands. On the test site, plain
wp cron event listandwp evalcalls also sent the loopbackPOSTwhen an event was due. -
A full-page cache reduces the triggers. A request answered by a page cache or a CDN never reaches PHP, so a busy but fully cached site can still run cron late. See Performance and Caching.
-
A late recurring event runs once, not once per missed interval. When it is rescheduled, the next run keeps the original phase: the next time is
now + (interval - now - timestamp) % interval. -
The jobs run as no user. Inside a cron callback
get_current_user_id()returns 0 andwp_doing_cron()returns true, so a callback that callscurrent_user_can()always fails. Logged on the test site:lp_whoami user=0 doing_cron=true mem=256M. -
The lock is a transient. With a persistent object cache (Redis, Memcached)
doing_cronlives in the cache, not inwp_options, andwp-cron.phpreads it from there. See Options, Transients and Object Cache.
The request filters are cron_request (URL and arguments of the loopback request) and https_local_ssl_verify
(certificate checking of the loopback, false by default, so a self-signed certificate on a staging host does not break
it).
The cron option
All events are stored in one serialized array, the cron row of wp_options. It is autoloaded, so every request reads
it. Its keys are Unix timestamps in UTC, sorted in ascending order. Under each timestamp come the hook names, and under
each hook a key that is the MD5 hash of the serialized arguments. The last key, version, is the format version, 2
since WordPress 2.1. Part of the option on the test site, read with wp option get cron --format=json:
{
"1793187400": {
"wp_version_check": {
"40cd750bba9870f18aada2478b24840a": {
"schedule": "twicedaily",
"args": [],
"interval": 43200
}
}
},
"1793205495": {
"lp_catalog_sync_stock": {
"40cd750bba9870f18aada2478b24840a": {
"schedule": "lp_every_six_hours",
"args": [],
"interval": 21600
}
}
},
"version": 2
}
40cd750bba9870f18aada2478b24840a is md5( serialize( array() ) ), the key of every event without arguments. A single
event has "schedule": false and no interval. Because the arguments are part of the key, the same hook with
different arguments is a different event: array( 5 ) and array( '5' ) serialize differently and both get scheduled.
A plugin that passes a changing value (a timestamp, a random token) as an argument adds a new event on every call, and
the option grows until every request loads megabytes of cron data. Read and write the array only through the API;
_get_cron_array() and _set_cron_array() are private functions.
The core events of a fresh 7.1.3 site, and where they come from:
| Hook | Recurrence | Purpose and when it is scheduled |
|---|---|---|
|
Twice daily |
Update checks. Scheduled on install. Automatic updates ( |
|
Hourly |
Deletes personal-data export files older than three days |
|
Daily |
Deletes unconfirmed privacy requests |
|
Daily |
Removes expired recovery-mode keys |
|
Weekly |
Runs the Site Health tests in the background for the dashboard widget |
|
Daily |
Empties the trash after |
|
Daily |
Deletes auto-drafts older than seven days. Scheduled the first time the editor creates a post |
|
Single |
Publishes a scheduled post; see Posts and Pages |
|
Single or weekly |
Pingbacks after publishing, clean-up of update backups and of uploaded import files |
Inspecting and managing events with WP-CLI
wp cron has three subcommands: wp cron event (list, schedule, run, delete, unschedule), wp cron schedule list and
wp cron test. On the test site, after the lumen-catalog plugin was activated:
wp cron schedule list
wp cron event list --hook=lp_catalog_sync_stock --fields=hook,next_run_gmt,recurrence,schedule
Source: WP-CLI: wp cron event list
name display interval
hourly Once Hourly 3600
lp_every_six_hours Every six hours 21600
twicedaily Twice Daily 43200
daily Once Daily 86400
weekly Once Weekly 604800
hook next_run_gmt recurrence schedule
lp_catalog_sync_stock 2026-10-28 10:39:06 6 hours lp_every_six_hours
Running, scheduling and removing events:
wp cron event run lp_catalog_sync_stock # run one hook now, whatever its time
wp cron event run --due-now # run every event that is due
wp cron event schedule lp_catalog_sync_book '+10 minutes' --0=5 # single event with one argument
wp cron event schedule lp_report_demo now daily # recurring event, first run now
wp cron event unschedule lp_catalog_sync_book # remove every event of the hook
wp cron event delete lp_report_demo # same, by hook name
wp cron test # check that spawning (the loopback) works
Source: WP-CLI: wp cron event
Executed the cron event 'lp_catalog_sync_stock' in 0.01s.
Success: Executed a total of 1 cron event.
Success: Scheduled event with hook 'lp_catalog_sync_book' for 2026-10-28 10:49:14 GMT.
Success: Scheduled event with hook 'lp_report_demo' for 2026-10-28 10:39:15 GMT.
Success: Unscheduled 1 event for hook 'lp_catalog_sync_book'.
Success: Deleted a total of 1 cron event.
Success: WP-Cron spawning is working as expected.
Details that matter in practice:
-
wp cron event run <hook>runs a recurring event early and reschedules it one interval from now. On the test site, running the stock sync at 10:38:15 moved its next run from 10:39:06 to 16:38:15. -
Arguments given on the command line are strings:
--0=5stores["5"], which does not match an event a plugin scheduled with the integer5. Usewp evalwhen the type matters. -
In WP-CLI 2.12.0,
wp cron event run --due-nowdoes not check thedoing_cronlock. With the lock set a second earlier, it still ran the due events. The online command reference describes a version that respects the lock and adds--networkfor Multisite, but those options are not in 2.12.0 (wp help cron event runlists only<hook>,--due-now,--excludeand--all). A system cron that calls it must prevent overlapping runs itself, withflockas shown below. -
wp cron event listshows an event whose schedule no longer exists (because the plugin that registered the schedule is inactive) asNon-repeating, although itsschedulefield still holds the name.
Scheduling events from a plugin
The Lumen Press catalogue keeps the stock level of each book in sync with a fictitious distributor API every six hours.
The plugin registers the six-hour recurrence, schedules the event on activation, removes it on deactivation and does
the work in the hook callback. The custom post type lp_book and its meta are registered elsewhere in the plugin; see
Custom Post Types and Taxonomies. The lp_stock post meta
written here holds the current stock of each book; the plugin’s {prefix}lp_stock table, created in
Plugin Fundamentals, keeps the history of stock movements.
The listing below calls the distributor directly to stay self-contained. The production job reads the URL from the
LUMEN_DISTRIBUTOR_URL override or the https-only setting, as shown in
HTTP and Filesystem APIs, and goes
through the cached lp_catalog_get_distributor_stock() of
Options, Transients and Object Cache.
The main plugin file, lumen-catalog/lumen-catalog.php (only the lines relevant to cron):
<?php
/**
* Plugin Name: Lumen Catalog
* Description: Book catalogue for Lumen Press.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 7.4
* Text Domain: lumen-catalog
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/includes/stock-sync.php';
register_activation_hook( __FILE__, 'lp_catalog_schedule_stock_sync' );
register_deactivation_hook( __FILE__, 'lp_catalog_unschedule_stock_sync' );
The job, lumen-catalog/includes/stock-sync.php:
<?php
/**
* Stock synchronisation with the distributor, run by WP-Cron.
*/
defined( 'ABSPATH' ) || exit;
const LP_CATALOG_SYNC_HOOK = 'lp_catalog_sync_stock';
/**
* Adds a six-hour recurrence. The filter must be registered on every request,
* not only on activation, because WordPress reads it again when it reschedules.
*/
add_filter(
'cron_schedules',
static function ( array $schedules ): array {
$schedules['lp_every_six_hours'] = array(
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __( 'Every six hours', 'lumen-catalog' ),
);
return $schedules;
}
);
/**
* Schedules the recurring event once (activation hook).
*/
function lp_catalog_schedule_stock_sync(): void {
if ( wp_next_scheduled( LP_CATALOG_SYNC_HOOK ) ) {
return; // Recurring events have no duplicate check: always test first.
}
$result = wp_schedule_event( time() + MINUTE_IN_SECONDS, 'lp_every_six_hours', LP_CATALOG_SYNC_HOOK, array(), true );
if ( is_wp_error( $result ) ) {
error_log( 'Lumen Catalog: ' . $result->get_error_message() );
}
}
/**
* Removes every occurrence of the event (deactivation hook).
*/
function lp_catalog_unschedule_stock_sync(): void {
wp_clear_scheduled_hook( LP_CATALOG_SYNC_HOOK );
}
add_action( LP_CATALOG_SYNC_HOOK, 'lp_catalog_sync_stock' );
/**
* Fetches stock levels and stores them in the lp_stock meta of each book.
*/
function lp_catalog_sync_stock(): void {
$settings = get_option( 'lp_catalog_settings', array() );
$base_url = $settings['distributor_url'] ?? 'https://distributor.example';
$response = wp_remote_get(
trailingslashit( $base_url ) . 'v1/stock',
array(
'timeout' => 20,
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
error_log( 'Lumen Catalog: stock sync failed, will retry at the next run.' );
return;
}
$items = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $items ) ) {
return;
}
$updated = 0;
foreach ( $items as $item ) {
$books = get_posts(
array(
'post_type' => 'lp_book',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => 'lp_isbn',
'meta_value' => sanitize_text_field( $item['isbn'] ?? '' ),
)
);
if ( $books ) {
update_post_meta( $books[0], 'lp_stock', absint( $item['stock'] ?? 0 ) );
++$updated;
}
}
update_option( 'lp_catalog_last_sync', array( 'time' => time(), 'updated' => $updated ), false );
}
Source: Plugin Handbook: Scheduling WP Cron Events, Plugin Handbook: Understanding WP-Cron Scheduling
lp_catalog_settings is the non-autoloaded option described in
Database Schema. On the test site, with the distributor answer replaced by a
pre_http_request filter in a must-use plugin (two books, ISBNs ending in -1 and -8):
wp plugin activate lumen-catalog
wp cron event run lp_catalog_sync_stock
wp option get lp_catalog_last_sync --format=json
wp cron event list --hook=lp_catalog_sync_stock --fields=hook,next_run_gmt,recurrence
Source: WP-CLI: wp cron event run
Plugin 'lumen-catalog' activated.
Success: Activated 1 of 1 plugins.
Executed the cron event 'lp_catalog_sync_stock' in 0.01s.
Success: Executed a total of 1 cron event.
{"time":1793183895,"updated":2}
hook next_run_gmt recurrence
lp_catalog_sync_stock 2026-10-28 16:38:15 6 hours
wp plugin deactivate lumen-catalog left wp cron event list --hook=lp_catalog_sync_stock --format=count at 0, and
activating it again brought it back to 1.
The job above keeps the HTTP call inline to stay short. The version the plugin ships calls a helper that caches the distributor response (Options, Transients and Object Cache) and validates the URL, status and body before using them (HTTP and Filesystem APIs).
The scheduling functions
| Function | Behaviour |
|---|---|
|
Adds a recurring event. Fails with |
|
Adds a one-off event. Refuses ( |
|
Timestamp of the next event of the hook with exactly those arguments, or false. The standard guard before scheduling |
|
The event object ( |
|
The recurrence name of the next event, or false |
|
All recurrences, after the |
|
Removes one occurrence; a recurring event stops because the occurrence is gone |
|
Removes every event of the hook with those arguments; returns the number removed |
|
Removes every event of the hook, whatever the arguments (5.1+) |
The $wp_error parameter (5.7+) makes the functions return a WP_Error instead of false. The error codes seen on the
test site were invalid_schedule ("Event schedule does not exist."), duplicate_event ("A duplicate event already
exists.") and invalid_timestamp. The pre_schedule_event, pre_reschedule_event, pre_unschedule_event,
pre_clear_scheduled_hook, pre_unschedule_hook, pre_get_scheduled_event and pre_get_ready_cron_jobs filters
(5.1+) let a plugin move event storage out of the cron option entirely; the schedule_event filter can change or
veto an event.
Rules for cron_schedules:
-
wp_get_schedules()merges the filtered array under the core schedules, so a plugin cannot redefinehourly,twicedaily,dailyorweekly. Give custom schedules a prefixed name (lp_every_six_hours). -
The filter must run on every request.
wp_schedule_event()checks the recurrence when the event is created, andwp_reschedule_event()checks it again after every run. -
Very short intervals (one minute or less) do not make WP-Cron run more often than it is triggered, and the lock allows at most one run per
WP_CRON_LOCK_TIMEOUT.
What happens to events of a removed plugin
The Plugin Handbook warns that WordPress keeps trying to run events that were not unscheduled. On 7.1.3 the outcome depends on the recurrence:
-
Core recurrence (
hourly,daily, …): the event runs forever, firing a hook with no callback, and stays in thecronoption. -
Custom recurrence registered by the removed plugin: the event fires once more, rescheduling fails and the event is dropped. On the test site, the plugin files were deleted without deactivation and
wp-cron.phpwas requested; the event disappeared and PHP logged:
Cron reschedule event error for hook: lp_catalog_sync_stock, Error code: invalid_schedule, Error message: Event schedule does not exist., Data: {"schedule":"lp_every_six_hours","args":[],"interval":21600}
Either way, deactivation is the place to unschedule, and uninstall.php should do it again for the case where the
files are deleted while the plugin is inactive (see Plugin Fundamentals).
Leftover events of plugins removed long ago can be found with wp cron event list and removed with
wp cron event delete <hook>.
Running WP-Cron from a system scheduler
Driving WP-Cron from a real scheduler fixes the two weaknesses of page-load triggering: late events on quiet sites and the cost of the check on busy ones. It takes two steps, and both are required.
First, stop page loads from spawning cron in wp-config.php:
define( 'DISABLE_WP_CRON', true );
// Optional: allow a new cron run only after 120 seconds instead of 60.
define( 'WP_CRON_LOCK_TIMEOUT', 120 );
DISABLE_WP_CRON does not disable the scheduler; it only stops _wp_cron() from spawning it. Events are still
scheduled, and wp-cron.php still runs them when it is requested. Without the second step nothing runs at all:
scheduled posts are never published, update checks stop and the trash is never emptied. On the test site, with the
constant set, a front-end request a minute after an event’s due time left it at next_run_relative: now.
Second, call the scheduler every few minutes. Two ways, both verified on the test site.
With WP-CLI on the web server, in /etc/cron.d/lumenpress (the sixth field is the user; run as the web server user so
that files the jobs create have the right owner):
# m h dom mon dow user command
*/5 * * * * www-data flock -n /run/lock/lumenpress-cron.lock wp --path=/var/www/lumenpress cron event run --due-now --quiet
flock -n skips a run while the previous one is still going, which WP-CLI 2.12.0 does not check itself. This runs the
jobs in the PHP CLI, so the CLI php.ini limits apply (no max_execution_time by default), and it works even when the
site cannot reach itself over HTTP. With this method wp-cron.php can be blocked at the web server, removing a public
endpoint (see Web Servers: Apache and nginx).
Over HTTP, from any machine, including a scheduler outside the server:
*/5 * * * * curl -fsS -o /dev/null --max-time 300 'https://lumenpress.example/wp-cron.php?doing_wp_cron'
The handbook’s example uses wget --delete-after once a day at midnight; every five minutes is a better default,
because scheduled posts are then at most five minutes late. A request with an empty doing_wp_cron makes wp-cron.php
take the lock itself, so an overlapping request returns at once. This method runs the jobs in a PHP-FPM worker, under
the web server’s timeouts, and needs wp-cron.php to stay reachable. On the test site, GET /wp-cron.php returned 200
with an empty body and the due event was gone afterwards.
In containers, the same command runs from the host’s cron (docker compose exec), from a Kubernetes CronJob (see
Kubernetes: Jobs and CronJobs) or from a managed scheduler.
With several web nodes, run cron from one place only; see
Scaling and High Availability. The Kubernetes CronJob that is
the single scheduler of a multi-pod deployment, with its concurrency policy and verification, is worked through in
Object Cache, Sessions and Cron in a Cluster. On Multisite, each site has its
own cron option, so the command must run once per site, for example by looping over wp site list --field=url
and passing each URL to --url.
Site Health adjusts to the constant. With DISABLE_WP_CRON true, an event counts as late after 15 minutes and as
missed after an hour; without it, any overdue event is late and one more than five minutes overdue is missed.
ALTERNATE_WP_CRON
For hosts where loopback requests are blocked and no system scheduler is available, the constant below replaces the loopback with a redirect:
define( 'ALTERNATE_WP_CRON', true );
When an event is due, the visitor’s own GET request is answered with a 302 to the same URL plus
?doing_wp_cron=<lock>, and the jobs then run in that same PHP process. On the test site:
HTTP/1.1 302 Found
Location: /?doing_wp_cron=1793184520.3834390640258789062500
The visitor sees an extra redirect and a URL with a query string, which can end up in analytics, shared links and
caches. Only GET requests outside AJAX and XML-RPC trigger it, and since 6.9 it still runs on wp_loaded, before the
page is built. The Advanced Administration Handbook notes that the method has risks; prefer a system scheduler.
Loopback requests and troubleshooting
WP-Cron, the plugin and theme file editors, and the Site Health tests all depend on loopback requests: HTTP requests
from the server to its own public URL. When they fail, scheduled events silently stop. wp cron test sends a real
blocking request to wp-cron.php and reports the result:
$ wp cron test
Success: WP-Cron spawning is working as expected.
$ wp cron test # with DISABLE_WP_CRON true, exit code 1
Error: The DISABLE_WP_CRON constant is set to true. WP-Cron spawning is disabled.
$ wp cron test # with a site URL that does not resolve from the server
Error: WP-Cron spawn failed with error: cURL error 6: Could not resolve host: offline.lumenpress.test (Domain name not found)
Source: WP-CLI: wp cron test
In the dashboard, Tools > Site Health runs the same kind of checks: "Scheduled events are running" or "A scheduled
event has failed" / "A scheduled event is late" (test scheduled_events), and "Your site can perform loopback
requests" (test loopback_requests). On the test site, an event two hours overdue produced the recommendation "The
scheduled event, lp_late_demo, failed to run." See
Debugging, Site Health and Troubleshooting.
| Symptom or cause | What to check and do |
|---|---|
"Missed schedule" on posts; update checks never run |
No traffic, a fully cached site, or |
|
The server cannot resolve or reach its own host name (common in containers, behind NAT or split DNS). Add the name to the server’s |
|
HTTP basic authentication on a staging site. Allow the server’s own address or use the WP-CLI system cron |
|
A firewall, WAF or security plugin blocks |
Events run, but some never finish |
A job exceeds |
Same job runs twice at once |
A job longer than |
|
Events with changing arguments, or orphaned events of removed plugins. Inspect with |
Event in the past that never runs |
Its callback is attached only in the admin, or behind a condition false in |
Security considerations
-
wp-cron.phpis a public URL that needs no authentication. The lock limits how often it does work, and with nothing due it exits at once, but it still loads WordPress on every request. With a WP-CLI system cron it can be denied at the web server or edge; see Security Hardening. -
Cron callbacks run with no current user, so they must not rely on capability checks for access control; anything a callback does is done with the full authority of the code.
-
Arguments stored with an event come back unchanged from the database, but treat them as data: validate post IDs and re-read settings at run time rather than storing secrets or credentials in event arguments, which are readable in
wp_optionsand inwp cron event list. -
A job that calls an external API, like the stock sync, needs a timeout and error handling. A cron run that hangs on an HTTP request holds a PHP worker and, after
WP_CRON_LOCK_TIMEOUT, lets the next run start alongside it. The HTTP API is covered in HTTP and Filesystem APIs.
Action Scheduler: a job queue for larger workloads
WP-Cron is a scheduler, not a queue. It keeps no history, no retry, no record of failures, and every event lives in one autoloaded option, so it is not suited to thousands of jobs. Action Scheduler is an open-source library that fills that gap. It is not part of core: it is maintained by Automattic and bundled by WooCommerce (its use there is covered in Action Scheduler) and by many other plugins. At the check date the latest release was 4.2.0 (2026-09-16), which requires WordPress 6.9 and PHP 7.2. When several plugins bundle it, only the newest copy is loaded.
| Aspect | WP-Cron | Action Scheduler |
|---|---|---|
Storage |
One autoloaded option |
Custom tables: |
Volume |
Tens of events |
Hundreds of thousands of actions, processed in batches |
History and failures |
None |
Every action keeps a status ( |
Async jobs |
A single event at |
|
Schedules |
Intervals |
Intervals and cron expressions ( |
Admin screen |
None in core |
Tools > Scheduled Actions |
Runner |
Page loads or a system cron |
A WP-Cron event every minute ( |
Action Scheduler itself depends on WP-Cron or on its WP-CLI runner, so the system-cron advice above applies to it too.
The Lumen Press stock sync as a queue: the recurring action only enqueues one small asynchronous action per book, and
saving a book enqueues its own sync. The library is installed with Composer, pinned to an exact version
(composer require woocommerce/action-scheduler:4.2.0), which places it in vendor/woocommerce/action-scheduler/.
In lumen-catalog.php, the library must be loaded before plugins_loaded priority 0, and the cron version of the
activation and deactivation hooks is replaced:
// Action Scheduler must be loaded before plugins_loaded priority 0 runs.
require_once __DIR__ . '/vendor/woocommerce/action-scheduler/action-scheduler.php';
require_once __DIR__ . '/includes/stock-sync-queue.php';
register_activation_hook( __FILE__, 'lp_catalog_ensure_stock_sync' );
register_deactivation_hook( __FILE__, 'lp_catalog_unschedule_queue' );
Source: Action Scheduler: Usage
lumen-catalog/includes/stock-sync-queue.php:
<?php
// Stock synchronisation through Action Scheduler instead of WP-Cron.
defined( 'ABSPATH' ) || exit;
const LP_CATALOG_GROUP = 'lumen-catalog';
// Runs on activation and on Action Scheduler's daily "ensure recurring actions" hook.
function lp_catalog_ensure_stock_sync(): void {
if ( ! as_has_scheduled_action( 'lp_catalog_sync_stock', array(), LP_CATALOG_GROUP ) ) {
as_schedule_recurring_action( time(), 6 * HOUR_IN_SECONDS, 'lp_catalog_sync_stock', array(), LP_CATALOG_GROUP, true );
}
}
add_action( 'action_scheduler_ensure_recurring_actions', 'lp_catalog_ensure_stock_sync' );
// Saving a book queues its own sync, so a slow distributor never delays the save.
add_action(
'save_post_lp_book',
static function ( int $post_id ): void {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
as_enqueue_async_action( 'lp_catalog_sync_book', array( 'post_id' => $post_id ), LP_CATALOG_GROUP, true );
}
);
add_action( 'lp_catalog_sync_stock', 'lp_catalog_queue_all_books' );
add_action( 'lp_catalog_sync_book', 'lp_catalog_sync_book' );
// The recurring action only splits the work into one small action per book.
function lp_catalog_queue_all_books(): void {
$ids = get_posts(
array(
'post_type' => 'lp_book',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
foreach ( $ids as $post_id ) {
as_enqueue_async_action( 'lp_catalog_sync_book', array( 'post_id' => $post_id ), LP_CATALOG_GROUP, true );
}
}
// An exception marks the action as failed, with the message in its log.
function lp_catalog_sync_book( int $post_id ): void {
$isbn = (string) get_post_meta( $post_id, 'lp_isbn', true );
$settings = get_option( 'lp_catalog_settings', array() );
$base_url = $settings['distributor_url'] ?? 'https://distributor.example';
$response = wp_remote_get( trailingslashit( $base_url ) . 'v1/stock/' . rawurlencode( $isbn ), array( 'timeout' => 20 ) );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
throw new RuntimeException( "Distributor did not answer for ISBN {$isbn}." );
}
$item = json_decode( wp_remote_retrieve_body( $response ), true );
update_post_meta( $post_id, 'lp_stock', absint( $item['stock'] ?? 0 ) );
}
// Deactivation hook: cancels every pending action of the group.
function lp_catalog_unschedule_queue(): void {
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( '', array(), LP_CATALOG_GROUP );
}
}
Source: Action Scheduler: API Functions
The action_scheduler_ensure_recurring_actions hook fires once a day (at 03:00 site time), so the activation hook is
what registers the action on the first day. The sixth argument true ($unique) stops a second identical pending
action from being queued. On the test site, with the distributor answering 503 for the second book:
wp action-scheduler run --hooks=lp_catalog_sync_stock
wp action-scheduler run --hooks=lp_catalog_sync_book
wp action-scheduler action list --group=lumen-catalog --fields=id,hook,status,args,recurring
Source: Action Scheduler: WP-CLI
Found 1 scheduled task
Started processing action 9
Completed processing action 9 with hook: lp_catalog_sync_stock
1 batch executed.
Found 2 scheduled tasks
Started processing action 11
Error: Error processing action 11: Distributor did not answer for ISBN 978-1-23456-793-4.
Started processing action 12
Completed processing action 12 with hook: lp_catalog_sync_book
1 batch executed.
id hook status args recurring
9 lp_catalog_sync_stock complete [] yes
12 lp_catalog_sync_book complete {"post_id":5} no
11 lp_catalog_sync_book failed {"post_id":6} no
13 lp_catalog_sync_stock pending [] yes
Running the recurring action 9 created action 13 for the next run. Deactivating the plugin set action 13 to canceled
and kept the history. On the test site, 4.2.0’s run ended each batch with "Success: 0 scheduled tasks completed."
although it had completed them, and run --group=lumen-catalog failed with "The group "lumen-catalog" does not exist.",
so filter by --hooks.
Other ecosystem options: WP Crontrol (a plugin that lists, edits and runs WP-Cron events from Tools > Cron Events), and
storage replacements such as Cavalcade that use the pre_* filters to keep events in a table and run them from a
daemon.
Corrections to older books
The older books cover WP-Cron briefly or not at all, and some of their advice no longer applies:
| Older advice or omission | Current behaviour |
|---|---|
Scheduled posts are published "at the chosen time" |
Only if WP-Cron is triggered. On a quiet or fully cached site, posts are late or show "Missed schedule" until a request or a system cron runs it |
Disable WP-Cron ( |
Only together with a system cron. On its own it stops publishing, update checks, auto-updates and clean-up |
The cron check slows every page |
Since 6.9 the spawn happens on |
A plugin schedules its task on every page load |
Schedule once on activation (or behind |
Use |
A last resort: it redirects visitors. Fix the loopback or use a system cron |
Debug cron with a plugin that lists events |
WP-CLI ( |