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 (*/5 * * * *)

An interval in seconds from a first run (hourly, daily, custom)

Where jobs are stored

Crontab files

The cron row of wp_options

Who can add jobs

The server administrator

Any plugin or theme, from PHP

The sequence for a request that finds a due event:

  1. Core hooks wp_cron() to init. Since 6.9, wp_cron() defers the real work, _wp_cron(), to the shutdown action, so the page is sent to the browser before WordPress starts the cron request. Before 6.9 the check ran on wp_loaded and added its time to the Time To First Byte.

  2. _wp_cron() returns at once if the request is for wp-cron.php itself or if DISABLE_WP_CRON is true. Otherwise it reads the ready events and, if the first one is due, calls spawn_cron().

  3. spawn_cron() reads the lock, the transient doing_cron. If a cron run started less than WP_CRON_LOCK_TIMEOUT seconds ago (default 60), it does nothing. Otherwise it stores the current time with microseconds in doing_cron and sends a loopback request: a POST to its own site_url( 'wp-cron.php' ) with ?doing_wp_cron=<lock value>, a timeout of 0.01 seconds and blocking false, so the visitor’s request does not wait for the jobs.

  4. wp-cron.php calls ignore_user_abort( true ), closes the connection with fastcgi_finish_request() when PHP-FPM provides it, defines DOING_CRON, loads WordPress and raises the memory limit to WP_MAX_MEMORY_LIMIT (wp_raise_memory_limit( 'cron' )).

  5. 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 deletes doing_cron.

sequenceDiagram autonumber actor Visitor participant Page as index.php (PHP worker) participant DB as wp_options participant Cron as wp-cron.php (PHP worker) participant Plugin as lp_catalog_sync_stock Visitor->>Page: GET /books/ Page->>DB: read cron option (autoloaded) Page-->>Visitor: HTML response Note over Page: shutdown action: _wp_cron() Page->>DB: read transient doing_cron alt lock older than WP_CRON_LOCK_TIMEOUT and an event is due Page->>DB: set doing_cron = now Page-)Cron: POST /wp-cron.php?doing_wp_cron=lock (non-blocking, 0.01 s) Cron->>DB: lock still ours? loop each due event Cron->>DB: reschedule recurring event, unschedule this run Cron->>Plugin: do_action_ref_array(hook, args) Plugin-->>Cron: done Cron->>DB: lock still ours? end Cron->>DB: delete doing_cron else locked or nothing due Note over Page: nothing to do end

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 list and wp eval calls also sent the loopback POST when 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 and wp_doing_cron() returns true, so a callback that calls current_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_cron lives in the cache, not in wp_options, and wp-cron.php reads 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

wp_version_check, wp_update_plugins, wp_update_themes

Twice daily

Update checks. Scheduled on install. Automatic updates (wp_maybe_auto_update) are scheduled from the version check; see Updates and Auto-Updates

wp_privacy_delete_old_export_files

Hourly

Deletes personal-data export files older than three days

wp_privacy_personal_data_cleanup_requests

Daily

Deletes unconfirmed privacy requests

recovery_mode_clean_expired_keys

Daily

Removes expired recovery-mode keys

wp_site_health_scheduled_check

Weekly

Runs the Site Health tests in the background for the dashboard widget

wp_scheduled_delete, delete_expired_transients

Daily

Empties the trash after EMPTY_TRASH_DAYS and deletes expired transients. Scheduled on the first admin screen load, not on install

wp_scheduled_auto_draft_delete

Daily

Deletes auto-drafts older than seven days. Scheduled the first time the editor creates a post

publish_future_post

Single

Publishes a scheduled post; see Posts and Pages

do_pings, wp_delete_temp_updater_backups, importer_scheduled_cleanup

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
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
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=5 stores ["5"], which does not match an event a plugin scheduled with the integer 5. Use wp eval when the type matters.

  • In WP-CLI 2.12.0, wp cron event run --due-now does not check the doing_cron lock. 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 --network for Multisite, but those options are not in 2.12.0 (wp help cron event run lists only <hook>, --due-now, --exclude and --all). A system cron that calls it must prevent overlapping runs itself, with flock as shown below.

  • wp cron event list shows an event whose schedule no longer exists (because the plugin that registered the schedule is inactive) as Non-repeating, although its schedule field 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 );
}

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
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

wp_schedule_event( $timestamp, $recurrence, $hook, $args = array(), $wp_error = false )

Adds a recurring event. Fails with invalid_schedule if the recurrence is not registered. No duplicate check: calling it twice with times a few seconds apart created two identical daily events on the test site

wp_schedule_single_event( $timestamp, $hook, $args = array(), $wp_error = false )

Adds a one-off event. Refuses (duplicate_event) an identical hook and arguments within ten minutes of an existing one

wp_next_scheduled( $hook, $args = array() )

Timestamp of the next event of the hook with exactly those arguments, or false. The standard guard before scheduling

wp_get_scheduled_event( $hook, $args = array(), $timestamp = null )

The event object (hook, timestamp, schedule, args, interval)

wp_get_schedule( $hook, $args = array() )

The recurrence name of the next event, or false

wp_get_schedules()

All recurrences, after the cron_schedules filter

wp_unschedule_event( $timestamp, $hook, $args = array(), $wp_error = false )

Removes one occurrence; a recurring event stops because the occurrence is gone

wp_clear_scheduled_hook( $hook, $args = array(), $wp_error = false )

Removes every event of the hook with those arguments; returns the number removed

wp_unschedule_hook( $hook, $wp_error = false )

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 redefine hourly, twicedaily, daily or weekly. 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, and wp_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 the cron option.

  • 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.php was 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)

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 DISABLE_WP_CRON without a system cron. Add the system cron above

cURL error 6 or 7 in wp cron test

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 /etc/hosts or use the WP-CLI system cron

401 from the loopback

HTTP basic authentication on a staging site. Allow the server’s own address or use the WP-CLI system cron

403 or a challenge page

A firewall, WAF or security plugin blocks wp-cron.php or requests with the WordPress/<version> user agent. Allow-list the server’s address

Events run, but some never finish

A job exceeds max_execution_time or the PHP-FPM request_terminate_timeout, or a fatal error stops the loop. Check the PHP error log and split long jobs

Same job runs twice at once

A job longer than WP_CRON_LOCK_TIMEOUT: a second process takes the lock and the first one stops after its current event. Raise the constant or make the job resumable

cron option of several megabytes

Events with changing arguments, or orphaned events of removed plugins. Inspect with wp cron event list --fields=hook,args

Event in the past that never runs

Its callback is attached only in the admin, or behind a condition false in wp-cron.php (is_admin() is false there). Attach cron callbacks unconditionally

Security considerations

  • wp-cron.php is 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_options and in wp 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: wp_actionscheduler_actions, _claims, _groups, _logs

Volume

Tens of events

Hundreds of thousands of actions, processed in batches

History and failures

None

Every action keeps a status (pending, in-progress, complete, failed, canceled) and a log

Async jobs

A single event at time()

as_enqueue_async_action()

Schedules

Intervals

Intervals and cron expressions (as_schedule_cron_action())

Admin screen

None in core

Tools > Scheduled Actions

Runner

Page loads or a system cron

A WP-Cron event every minute (action_scheduler_run_queue), async loopback requests from admin pages, and wp action-scheduler run

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' );

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 );
	}
}

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
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 (DISABLE_WP_CRON) to speed up the site

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 shutdown, after the page is sent; the remaining cost is reading the autoloaded cron option

A plugin schedules its task on every page load

Schedule once on activation (or behind wp_next_scheduled()), and unschedule on deactivation; recurring events have no duplicate check

Use ALTERNATE_WP_CRON when cron does not run

A last resort: it redirects visitors. Fix the loopback or use a system cron

Debug cron with a plugin that lists events

WP-CLI (wp cron event list, wp cron test) and Site Health’s scheduled events and loopback tests are built in