TL;DR
- WP-Cron is not a real scheduler —
wp_cron()runs on every request, and when events are due it dispatches a non-blocking loopback towp-cron.php(atshutdownsince WordPress 6.9). wp_doing_cron()returns true inside that loopback request (whenDOING_CRONis defined and truthy; thewp_doing_cronfilter can override it). Use it to guard long-running callbacks.DISABLE_WP_CRONinwp-config.phpdisables the page-load trigger but does not disablewp-cron.phpitself — that is the foundation of every real-cron fix.
/ Definition
What is WP-Cron and how does it actually fire?
WP-Cron is a userland scheduler written in PHP. It is not a Unix cron daemon. It runs entirely inside WordPress and is triggered by HTTP requests to your site — most commonly by visitor page loads, but also by the WordPress admin, the REST API, and explicit calls to spawn_cron().
The flow on every request: WordPress boots and runs wp_cron() on init. Since WordPress 6.9 that function only queues _wp_cron() for the shutdown action (with ALTERNATE_WP_CRON it still runs on wp_loaded, as it did for every site in 5.7–6.8). _wp_cron() checks the cron option in the database for events whose time field is in the past, and — if any are due — calls spawn_cron() to fire a non-blocking wp_remote_post() loopback request against wp-cron.php. The visitor's response is returned without waiting for the cron run to finish.
The canonical handler is wp-cron.php at the WordPress root. It defines the DOING_CRON constant, loads WordPress, iterates the due events, and for each one reschedules it (recurring) or unschedules it (one-shot) first, then runs do_action_ref_array( $hook, $args ) — so the args arrive as separate callback parameters.
See the official WP-Cron handbook for the canonical description of this lifecycle.
/ wp_doing_cron()
What does wp_doing_cron() return and when do you use it?
It returns true when the current PHP request is the cron loopback — that is, when the DOING_CRON constant has been defined and is truthy. It returns false during normal visitor requests, admin requests, REST requests, and WP-CLI commands (except for wp cron event run, which defines DOING_CRON itself — but only right before the first event runs, so during plugins_loaded and init it is still false).
Use it to guard long-running work that should never block a user-facing request. A common pattern: an add_action('my_event', ...) callback that does file processing wraps its body in if ( wp_doing_cron() ) as a defensive check, so if the hook is ever fired in another context the work is skipped instead of stalling the request.
Internally it is one line: return apply_filters( 'wp_doing_cron', defined( 'DOING_CRON' ) && DOING_CRON ); — so any plugin can override the result through the filter of the same name. It declares no return type; the docblock says bool. See the wp_doing_cron() reference.
wp_doing_cron() and spawn_cron() in practice
// Inside any request that wants to trigger overdue cron events. if ( ! wp_doing_cron() && _get_cron_array() ) { spawn_cron(); // fires a non-blocking loopback to /wp-cron.php?doing_wp_cron } // Inside a callback registered with add_action('my_cron_event', ...). function my_cron_callback() { if ( wp_doing_cron() ) { // Safe to do long-running work — no user is waiting on this PHP request. } }
/ spawn_cron()
How does spawn_cron() work under the hood?
It fires a wp_remote_post() request to site_url('/wp-cron.php?doing_wp_cron=<timestamp>') with blocking => false. The current request continues immediately; the loopback request runs in a separate PHP process. This is the mechanism that lets the user-facing response return quickly even when cron has work to do. See the spawn_cron() reference.
Before firing, spawn_cron() writes a doing_cron transient with the same timestamp. This is a soft lock — a second concurrent request still reaches spawn_cron(), but it sees the lock is younger than WP_CRON_LOCK_TIMEOUT (60 seconds by default, overridable with a define in wp-config.php) and returns false without firing another loopback. The transient prevents a swarm of overlapping cron runs on a busy site.
It is safe to call spawn_cron() from your own code if you want to force-fire overdue events without waiting for the next page load. Wrap it in if ( ! wp_doing_cron() ) to avoid recursive loopbacks.
/ DISABLE_WP_CRON
What does DISABLE_WP_CRON actually disable?
Defined as define( 'DISABLE_WP_CRON', true ); in wp-config.php, it suppresses the page-load trigger only. The check happens inside _wp_cron(), the callback that wp_cron() schedules — if the constant is truthy, it returns without spawning anything. Visitor requests stop firing cron. Note that spawn_cron() itself does not check the constant, so a direct call from your own code still fires a loopback.
It does not disable wp-cron.php itself. The script remains publicly accessible and still iterates due events when hit directly. This is the entire point: pair DISABLE_WP_CRON with a system crontab entry that hits wp-cron.php every minute, and you get reliable time-based execution decoupled from traffic. The WordPress cron job generator writes that entry for a chosen interval, user and method.
It also does not disable scheduling — wp_schedule_event() still writes to the cron option, plugins still register their schedules, and the events still execute when wp-cron.php runs. The constant only affects when cron runs, not whether it runs.
Common mistake: setting DISABLE_WP_CRON without configuring a real cron. The page-load trigger is gone, no system cron exists, so wp-cron.php never runs and every scheduled event silently stops firing. Always pair them — see the system cron setup guide.
/ WP-CLI
Which WP-CLI commands run a real cron sweep?
wp cron event run --due-now is the workhorse. It runs every event whose time is in the past, in the current PHP process (no loopback), with DOING_CRON defined so wp_doing_cron() behaves correctly inside callbacks. This is what you put in your system crontab.
Use wp cron event run <hook> to fire a single named event on demand — useful during plugin development and when investigating a single misbehaving callback. Use wp cron event list for inventory: every registered event, its next-run time, recurrence interval, and the hook name. wp cron test issues a loopback to wp-cron.php and reports whether the server can reach itself (a common failure on managed hosts with strict outbound firewalls). It refuses to run — exits with an error — when DISABLE_WP_CRON is set, so run it before you switch to a system cron.
WP-CLI cron commands — the daily-driver subset
# List every scheduled event. wp cron event list # Run every due event right now, in this PHP process (no loopback). wp cron event run --due-now # Run one specific hook on demand. wp cron event run my_cron_event # Show registered schedules (hourly, twicedaily, daily, plus custom). wp cron schedule list # Check whether wp-cron.php is reachable from the server itself (errors if DISABLE_WP_CRON is set). wp cron test
/ Real Cron
How do you replace WP-Cron with a real system cron?
Two lines of configuration. First, in wp-config.php above the /* That's all, stop editing! */ comment: define( 'DISABLE_WP_CRON', true );. Second, in the system crontab for the web user (commonly www-data on Debian/Ubuntu):
/etc/cron.d/wordpress — guaranteed one-minute execution
# Runs every minute regardless of site traffic. WP-CLI variant preferred when available. * * * * * www-data /usr/local/bin/wp --path=/var/www/site cron event run --due-now >/dev/null 2>&1 # Curl fallback if WP-CLI is not installed: * * * * * www-data curl -s https://your-site.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
The WP-CLI form is preferred because it runs in-process, captures errors directly, and avoids the HTTP loopback (which can be blocked, throttled, or rate-limited by upstream proxies). The curl form is the universal fallback and works on shared hosting where you cannot install WP-CLI.
/ Scheduling API
Where do wp_schedule_event and wp_schedule_single_event fit?
wp_schedule_event( $timestamp, $recurrence, $hook, $args, $wp_error ) registers a recurring event. $recurrence must match a registered schedule name (hourly, twicedaily, daily, weekly in core; cron_schedules filter to add custom). It writes to the cron option and is not idempotent: it has no duplicate check, so calling it on every load with time() as the timestamp adds another recurring event each time. Guard it with if ( ! wp_next_scheduled( $hook, $args ) ).
wp_schedule_single_event( $timestamp, $hook, $args ) registers a one-shot event. It deduplicates within a ten-minute window: scheduling the same hook + args twice within ten minutes produces only one event. Use it as the WordPress-native pattern for "do this work later, off the request path."
Pair both with add_action( $hook, $callback ) in the same execution path that will run cron — typically the same plugin file, so the callback is registered every time WordPress loads.
/ vs Action Scheduler
How is WP-Cron different from Action Scheduler?
| Capability | WP-Cron | Action Scheduler |
|---|---|---|
| Storage | cron option (autoloaded) | Dedicated DB tables |
| Scale ceiling | One autoloaded option — grows with every event | 100k+ actions per day |
| Concurrency | Single loopback per tick | Configurable concurrent batches |
| Failure recovery | Silent — event just doesn't run | Per-action status + log (no automatic retry) |
| Observability | No log of past runs | Status > Scheduled Actions UI |
| Trigger | Page load or system cron | WP-Cron event + admin-request loopbacks, or WP-CLI |
By default Action Scheduler is triggered by WP-Cron — its queue runner is a scheduled cron event (action_scheduler_run_queue, every minute) — and it also starts async loopback runners at the end of admin requests. It has no hard dependency on WP-Cron: wp action-scheduler run drives the queue directly. Replacing WP-Cron with a system cron makes Action Scheduler's default trigger reliable; the two work together rather than competing. See the Action Scheduler concurrent batches reference for the throughput tuning that depends on a reliable cron firing the queue runner.
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.