TL;DR
- WP-Cron is not a real scheduler — it is a function called
wp_cron()that fires on page load and dispatches a non-blocking loopback towp-cron.php. wp_doing_cron()returns true only inside that loopback request (whenDOING_CRONis defined). 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, runs the wp_cron() function near the end of wp_loaded, 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 runs do_action( $hook, $args ) for each.
Visitor request
│
▼
WordPress boot ── wp-includes/default-filters.php
│
├─ DISABLE_WP_CRON defined truthy? ──► skip, do not spawn
│
▼
wp_cron() reads cron array (option: cron)
│
├─ no events due ──► return, end request
│
▼
spawn_cron()
│
▼
wp_remote_post( wp-cron.php?doing_wp_cron, blocking=false )
│
▼
── visitor request ends ──
│
(separate PHP request)
▼
wp-cron.php
│
├─ define( 'DOING_CRON', true )
├─ load WordPress
├─ for each due event:
│ do_action( $hook, $args )
└─ exit 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. It returns false during normal visitor requests, admin requests, REST requests, and WP-CLI commands (except for wp cron event run, which sets DOING_CRON manually).
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 a thin wrapper around wp_doing_cron() applies the filter of the same name. The signature is wp_doing_cron(): 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 one-minute soft lock — a second concurrent visitor will see the transient is set, will not call spawn_cron() again, and will trust that the existing loopback is in progress. 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() — if the constant is truthy, the function exits immediately without spawning anything. Visitor requests stop firing cron.
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.
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).
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. 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 idempotent only when $args match exactly — different args produce different scheduled events.
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 | ~100 events before option bloat | 100k+ actions per day |
| Concurrency | Single loopback per tick | Configurable concurrent batches |
| Failure recovery | Silent — event just doesn't run | Per-action status + retry |
| Observability | No log of past runs | Status > Scheduled Actions UI |
| Trigger | Page load or system cron | Same — uses WP-Cron underneath |
Action Scheduler is built on top of WP-Cron — its queue runner is itself a scheduled cron event. Replacing WP-Cron with a system cron makes Action Scheduler 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.