WordPress Cron Is Not Cron: Fixing Scheduled Tasks
Why wp cron misses scheduled tasks, how to disable wp cron safely, and the WP-CLI crontab setup, retry handling and monitoring we use on client sites.
- WP-Cron is triggered by page requests, not by time. A low-traffic site runs scheduled tasks late or never, and a high-traffic site spawns a loopback request to
wp-cron.phpup to once every 60 seconds, which is wasted work. wp-cron.phpunschedules a single event before it fires the callback. A PHP fatal, an OOM, or a PHP-FPMrequestterminatetimeoutkill means the job is gone with no retry and no log entry.- The correct setup is
define('DISABLEWPCRON', true)plus a system crontab runningwp cron event run --due-nowevery minute, with a heartbeat ping so you find out about failures before your client does. - Duplicate scheduled events usually come from argument mismatch: cron array keys are
md5(serialize($args)), soarray(1)andarray('1')are different events andwpnextscheduled()won’t dedupe them. - For anything with retries, ordering, concurrency limits or more than a few hundred jobs, use Action Scheduler or an third-party queue. WP-Cron is a trigger, not a job runner.
What wp cron actually is under the hood
There is no daemon. There is an option in your database called cron, which is an array keyed by UTC timestamp, and a function hooked to init called wpcron(). On most page loads, that function reads the cron array, checks whether anything is due, and if so calls spawncron().
spawncron() does two things worth understanding. It sets a doingcron transient as a lock, then fires a non-blocking HTTP request to your own wp-cron.php with a 0.01 second timeout. It doesn’t wait for a response. It doesn’t know whether the request succeeded. The visitor’s page render continues immediately, which is why WP-Cron doesn’t usually show up in your TTFB numbers even though it’s firing constantly.
That loopback request is the fragile part. It has to resolve your own domain from inside your own server, pass through whatever proxy, WAF or CDN sits in front, and arrive at PHP. Things that break it silently:
- HTTP Basic Auth on a staging environment. The loopback gets a 401 and nothing ever runs.
- Cloudflare bot rules or rate limiting on
wp-cron.php. define('WPHTTPBLOCKEXTERNAL', true)without adding the site’s own host toWPACCESSIBLE_HOSTS.- Split-horizon DNS where the public A record points at a load balancer the origin cannot reach.
- IPv6 resolution succeeding, IPv6 routing not.
- A redirect from
wwwto apex that the request follows into a loop.
The lock is also rate-limited to WPCRONLOCK_TIMEOUT, which defaults to 60 seconds and is floored at 60 by core. So the best possible resolution of unmodified WP-Cron is once a minute, and only if someone visits.

The failure modes we actually see
The silent single-event loss
Open wp-cron.php in core and read the loop. For each due hook it calls wprescheduleevent() if the event is recurring, then wpunscheduleevent(), and only then doactionref_array(). The job is removed from the queue before your callback runs.
A fatal error inside your callback deletes the job. No retry, no dead letter, nothing in the queue to inspect afterwards. It gets worse: all due events run sequentially in a single PHP request, so one fatal takes out every job queued behind it in that batch. A single malformed image in a thumbnail regeneration task can block a nightly database backup for weeks, and nothing will tell you.
The autoloaded cron option
The cron option is autoloaded. Every page load on every request unserialises it. When a plugin schedules one event per order, per subscriber or per CPT and never cleans up, that option grows into the megabytes and your whole site slows down for reasons that look nothing like cron. If your wp_options table is already a mess, this compounds fast. Our walkthrough on database bloat in WordPress is worth reading alongside this.
Check it in one command:
# How many scheduled events exist, and how big is the option?
wp cron event list --format=count
wp option get cron --format=json | wc -c
Any events whose callback no longer exists? (ghost hooks from deleted plugins)
wp cron event list --fields=hook,nextrunrelative,recurrence
Anything over about 200 events on a normal business site deserves investigation. Over 1,000 and something is looping.
The duplicate storm
This one is subtle. Events are keyed inside each timestamp bucket by md5( serialize( $args ) ). So these are two separate events:
wpschedulesingleevent( time() + 60, 'acmesync_user', array( 42 ) ); // int
wpschedulesingleevent( time() + 60, 'acmesync_user', array( '42' ) ); // string
If one code path passes $userid straight from a hook and another passes it from $POST, wpnextscheduled() returns false for one of them and you get two jobs. Do that in a hook that fires on every admin page load and you’ll have thousands by Friday. Core added a 10 minute duplicate window for wpschedulesingle_event back in 5.1, which helps, but it only catches identical args.
How to disable wp cron and run it from a real scheduler
Two parts. People routinely do only the first.
/ wp-config.php, above the "stop editing" line /
// Stops WordPress spawning the loopback request on page loads.
// It does NOT stop events being scheduled or wp-cron.php from working.
define( 'DISABLEWPCRON', true );
Then the actual scheduler. Use WP-CLI, not wget on wp-cron.php. WP-CLI gives you a real exit code, real stderr, no HTTP layer to break, and no CDN in the path:
# /etc/cron.d/example-wp (0644, must end with a newline)
[email protected]
* www-data cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet 2>&1 | logger -t wp-cron
Note the cd into the install root rather than --path=. Both work, but cd means a relative wp-config.php lookup behaves identically to how the site runs, which matters on setups where the config lives one directory above webroot.
If you must use HTTP (shared hosting with no SSH, which is a reason to reconsider your hosting choice), at least do it properly:
/5 * curl -fsS -m 300 -o /dev/null https://example.com/wp-cron.php?doingwpcron
-f makes curl return non-zero on HTTP errors so your cron daemon can email you. -m 300 stops a hung request piling up overlapping invocations.
Skip ALTERNATEWPCRON unless you are cornered
ALTERNATEWPCRON works by appending doingwpcron to the current URL and redirecting the visitor’s browser through it. The visitor pays for your scheduled tasks with their page load, and the redirect shows up in analytics as weird URLs and occasionally gets cached by a CDN. It exists for hosts where loopback requests are impossible and you have no system cron. If you have system cron, there is no reason to touch it.
Managed hosts have already done half of this
Kinsta, WP Engine and Pressable, among others, ship DISABLEWPCRON and run the scheduler third-partyly. Read their docs for the interval before you assume it’s every minute, because some default to considerably longer. A 15 minute interval is fine for a nightly backup and quietly fatal for WooCommerce Subscriptions or anything built on Action Scheduler, which expects its queue runner roughly every minute.
Writing wordpress scheduled tasks that don’t fail silently
Four rules we apply on every build.
Register the hook unconditionally, schedule it conditionally. The callback must be attached on every request, not inside an is_admin() check and not inside an activation hook. A scheduled event whose hook has no listener fires, does nothing, and in the recurring case keeps rescheduling forever.
Build timestamps in UTC, deliberately. Cron timestamps are UTC. strtotime('tomorrow 3am') gives you server time, which on most hosts is also UTC but is not guaranteed and is definitely not the site timezone.
add_action( 'init', function () {
addaction( 'acmenightlysync', 'acmerunnightlysync' );
if ( wpnextscheduled( 'acmenightlysync' ) ) {
return;
}
// 03:15 in the SITE's timezone, converted to a UTC timestamp.
$firstrun = new DateTimeImmutable( 'tomorrow 03:15', wptimezone() );
wpscheduleevent( $firstrun->getTimestamp(), 'daily', 'acmenightly_sync' );
} );
function acmerunnightly_sync() {
// Own lock: the cron lock does not protect against two runners
// overlapping when a job takes longer than the interval.
if ( ! acmeacquirelock( 'nightly_sync', 900 ) ) {
return;
}
try {
acmedothe_work();
} catch ( Throwable $e ) {
// Catch Throwable, not Exception. TypeErrors are not Exceptions.
error_log( '[acme] nightly sync failed: ' . $e->getMessage() );
} finally {
acmereleaselock( 'nightly_sync' );
}
}
Catch Throwable and log. The event is already unscheduled by the time your code runs, so an uncaught error is data loss. Wrap the body, log the message, and if the task matters, re-schedule a retry inside the catch.
Clean up on deactivation. Call wpclearscheduledhook( 'acmenightly_sync' ) in your deactivation hook, with the exact same $args. Plugins that skip this step are the reason the cron option accumulates ghost hooks.
Keep individual runs short. PHP-FPM’s requestterminatetimeout kills the worker regardless of what settimelimit(0) says, so a 20 minute import run through an HTTP cron trigger will die at whatever the pool allows. Chunk it: process 50 items, schedule the next batch for 60 seconds out, exit.
When WP-Cron is the wrong tool entirely
WP-Cron is a trigger mechanism with a database-backed array behind it. No retry policy, no concurrency control, no priority, no per-job logging, no visibility into what failed. Once you need any of those, stop fighting it.
Action Scheduler is the pragmatic next step and it’s already on most Woo sites. It has its own tables, batches work, records per-action status and logs, and marks stuck actions as failed after a configurable window. It still needs a trigger (actionschedulerrun_queue every minute), but you can bypass WP-Cron entirely with wp action-scheduler run --batch-size=50 from your crontab, which is what we do on anything processing serious volume.
An third-party queue is correct when work arrives from outside and must not be lost: payment webhooks, third-party sync, anything where the sender retries on timeout. We wrote up the reasoning in queues for webhook processing, and all of it applies here. Accept, persist, acknowledge, process later from a worker you control.
An third-party scheduler beats both when timing precision matters. A Cloudflare Worker cron trigger, a GitHub Actions schedule or a tiny EC2 crontab hitting an authenticated REST endpoint gives you a real clock and a real failure signal. More moving parts, so only reach for it when a minute of drift actually costs something.
Monitoring, or your client tells you first
Everything above can be configured perfectly and then break because someone rotated the system user, moved the install, or updated WP-CLI. The fix is a dead man’s switch: your cron job pings a URL on success, and the monitoring service alerts you when the ping stops.
# Only pings when WP-CLI exits 0. Healthchecks.io alerts if the ping
is more than the grace period late.
* www-data cd /var/www/example.com \
&& /usr/local/bin/wp cron event run --due-now --quiet \
&& curl -fsS -m 10 -o /dev/null https://hc-ping.com/YOUR-UUID-HERE
For multisite, remember WP-Cron is per site and the loopback only ever hits one:
for url in $(wp site list --field=url --archived=0 --deleted=0); do
wp cron event run --due-now --quiet --url="$url"
done
Install WP Crontrol on any site you maintain. It lists every registered event, flags hooks with no callback, and lets you fire one manually to see the actual error instead of guessing. Three minutes to add it, hours saved repeatedly. For a quick verdict on whether the basic plumbing works at all, wp cron test checks the loopback and tells you plainly.
The short version to apply today
Run wp cron event list --format=count on your three busiest client sites. Above a few hundred events, you have a loop to find. Then set DISABLEWPCRON, add a one-minute WP-CLI crontab entry with a heartbeat ping, and wrap your own scheduled callbacks in try/catch(Throwable) with a log line.
Frequently Asked Questions
Does DISABLEWPCRON stop my scheduled tasks from running?
No. It only stops WordPress from spawning the loopback request to wp-cron.php during page loads. Events are still scheduled into the cron option and still run whenever wp-cron.php or wp cron event run is invoked. If you set the constant and forget the crontab, nothing runs at all, which is the single most common mistake with this change.
How often should the system cron run WP-Cron?
Every minute for anything transactional, including WooCommerce, Action Scheduler, subscriptions or email queues. Every 5 to 15 minutes is adequate for a brochure site whose only scheduled tasks are backups and transient cleanup. Running every minute costs almost nothing when nothing is due, because wp cron event run --due-now exits immediately.
Why do my scheduled events run late even with real cron set up?
Usually the lock or the batch. WPCRONLOCKTIMEOUT has a 60 second floor, so one overrunning job can block the batch behind it until it finishes. Check whether a single long task is hogging each run, and move it to its own crontab line with wp cron event run myslow_hook while excluding it from the main pass.
Is it safe to delete rows from the cron option directly?
Editing the serialised option by hand is risky because the keys are timestamp buckets and arg hashes. Use wp cron event delete <hook> for ghost hooks from removed plugins, and take a database dump first. If the option is genuinely corrupt beyond repair, deleting it entirely is survivable: WordPress rebuilds core events, but you will need to reactivate plugins to restore theirs.
Can I just use an third-party uptime monitor to hit wp-cron.php?
It works as a stopgap and it’s better than relying on traffic, but it’s weak. You get no exit code visibility, a CDN or WAF can intercept the request, and most monitors won’t wait long enough for a slow batch. If you have SSH, WP-CLI from a crontab with a heartbeat ping is both more reliable and easier to debug.
WP-Cron isn’t broken. It’s a pragmatic solution to the problem of scheduling in an environment where you cannot assume shell access, and judged on that it’s a reasonable design. The failure is treating it as a job runner when it’s a trigger.
Pick one site this week, audit the event count, and move it to a real scheduler with a heartbeat. You’ll know within a day whether anything had been quietly failing.


