Your scheduled post didn’t publish. Your nightly backup ran at 3pm instead of 3am. Your WooCommerce abandoned-cart emails went out a day late. Meanwhile, your server’s CPU graph looks like a seismograph during an earthquake — and WP-Cron is the fault line.
Both problems have the same root cause: WP-Cron was never a cron. It’s a hook that fires on page loads and pretends to be a scheduler. Depending on your traffic profile, that design decision is either making your site slow, making your jobs late, or — worst case — doing both at the same time.
This guide is for the moment you’ve stopped Googling “what is wp-cron” and started asking “how do I actually fix this properly in 2026?”. We’ll skip the beginner disclaimer, diagnose which failure mode your site is in, then move through the three paths you can take: keep WP-Cron and make it reliable, replace it with a real system cron, or offload everything to Action Scheduler. We’ll also cover the custom-scheduling gotchas nobody writes about until they’ve already shipped broken code to production.
The three failure modes of WP-Cron (diagnose before you fix)
Before touching wp-config.php, figure out which problem you have. There are only three. Fixing the wrong one wastes hours.
Failure mode 1: Low-traffic misses. Your site gets fewer than roughly 200 visits a day. WP-Cron only runs when someone hits a page, so at 3am when no one is browsing, your “daily” job doesn’t fire at all. When a visitor lands at 7am, the job runs four hours late. This is the classic “scheduled post didn’t publish” bug.
Failure mode 2: High-traffic tax. Your site gets thousands of page views per hour. On every uncached request, WordPress spawns a background wp-cron.php request to check the schedule. At scale that’s hundreds of wasted requests per hour, each one firing an entire WordPress bootstrap. You’ll see this as unexplained CPU spikes, database connection pileups, and slow TTFB on pages that should be fast.
Failure mode 3: Caching blindness. You run full-page caching (you should). Cached requests never hit PHP, so they never spawn cron. If your cache hit ratio is 95%, WP-Cron is effectively running on 5% of the traffic you thought it was — which often looks like failure mode 1 wearing a disguise.
Here’s the fast diagnosis. Install WP Crontrol temporarily and run this in WP-CLI:
wp cron event list --format=table --fields=hook,next_run_relative,recurrence
If you see events with a next_run_relative like 12 hours ago or 3 days ago, you have failure mode 1 or 3. If your server monitor shows constant wp-cron.php requests in the access log, you have failure mode 2. You might have all three — high-traffic cached sites often do.
Rule of thumb: if your
next_run_relativedrifts more than 10 minutes past the schedule on a job that matters, WP-Cron has failed you. Either the job isn’t important enough to replace it, or it’s time to move on.
The decision tree: WP-Cron, server cron, or Action Scheduler?
Most tutorials present these as three options with equal weight. They aren’t. Each has a specific job, and picking the wrong tool is why people end up with fragile infrastructure.
Use this table to decide — not to learn what each thing is.
| Scenario | Use |
|---|---|
| Small blog, few scheduled posts, no plugins doing heavy background work | Keep WP-Cron, maybe a 5-minute server cron as backup |
| High-traffic site with caching | Disable WP-Cron, real system cron every 5 minutes |
| WooCommerce store with subscriptions, webhooks, or bulk imports | Action Scheduler for the work, system cron for the trigger |
| Custom plugin that processes 10,000+ items | Action Scheduler, no exceptions |
| Time-critical job (must run within 60 seconds of schedule) | System cron every 1 minute, never WP-Cron |
| Job that must run exactly once per hour, no drift | System cron |
| Developer wants to test scheduling locally | WP-CLI wp cron event run + WP Crontrol |
If you scanned that and thought “I’m in three of those rows” — you probably are. The right answer is usually a combination. Disable WP-Cron to stop the traffic tax, trigger it via a system cron every 5 minutes so the WordPress-layer events still fire, and push any heavy work (imports, batch emails, API sync) into Action Scheduler so the cron request finishes in milliseconds.
Fix pseudo-cron the right way (the wget tutorial is wrong)
Open any of the top-ranking posts on this topic and you’ll find the same instructions: add define('DISABLE_WP_CRON', true);, then set up a server cron that runs wget -q -O - https://yoursite.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 every five minutes. That works on a dev server. On a real production site, it breaks for three reasons nobody bothers to mention.
First, wget with default options sends no user-agent. Cloudflare, Sucuri, and most managed WordPress hosts challenge or block empty-user-agent requests. You’ll see a 403 or a captcha HTML page, and the cron will silently never run. Second, HTTPS sites with HSTS or HTTP/2 sometimes respond poorly to wget depending on the build. Third, self-requests on Kinsta, WP Engine, and some Cloudways stacks get rate-limited as loopback requests.
The real fix is to bypass HTTP entirely. If you have SSH access, run wp-cron.php directly via PHP CLI — no self-request, no loopback, no user-agent problem. Here’s the complete setup.
Step 1 — Disable the pseudo-cron. Add this to wp-config.php above the That's all, stop editing! line:
define( 'DISABLE_WP_CRON', true );
Step 2 — Add a PHP-CLI cron entry. In your server crontab (crontab -e), add:
*/5 * * * * cd /var/www/yoursite.com/public_html && /usr/bin/php8.3 wp-cron.php > /dev/null 2>&1
That’s it. No HTTP round-trip, no firewall to dodge, no user-agent to spoof. On shared hosting without SSH, you have to use the HTTP method, and in that case use curl with an explicit user-agent:
*/5 * * * * curl -A "SiteCron/1.0" -s "https://yoursite.com/wp-cron.php?doing_wp_cron" > /dev/null 2>&1
On Kinsta and WP Engine, don’t build your own cron — both have a server-side cron built in that you can enable per-site from the dashboard. Use theirs. It’s already tuned around their loopback restrictions.
Step 3 — Verify it’s firing. After five minutes, check:
wp cron event list --fields=hook,next_run_relative --format=table
If next_run_relative values are counting down in real time, you’re done. If they’re still slipping, check your crontab logs (grep CRON /var/log/syslog on Ubuntu) and fix the path before blaming WordPress.
Writing custom scheduled tasks that don’t corrupt themselves
This is where most tutorials hand you a wp_schedule_event() snippet and walk away. That snippet looks innocent and will break in production the first time a job takes longer than its interval. Here’s what it looks like done right, with PHP 8.3 syntax and the guard rails the Codex example is missing.
<?php
/**
* Plugin Name: Swiftly Example Cron
*/
namespace Swiftly\ExampleCron;
const HOOK = 'swiftly_sync_remote_inventory';
const LOCK = 'swiftly_sync_inventory_lock';
const WINDOW = 15 * MINUTE_IN_SECONDS;
// Register on plugin activation — never on every page load.
register_activation_hook( __FILE__, function (): void {
if ( ! wp_next_scheduled( HOOK ) ) {
wp_schedule_event( time() + 60, 'hourly', HOOK );
}
} );
register_deactivation_hook( __FILE__, function (): void {
wp_clear_scheduled_hook( HOOK );
} );
add_action( HOOK, run_sync( ... ) );
function run_sync(): void {
// Prevent overlap. If another process holds the lock, bail out cleanly.
if ( get_transient( LOCK ) ) {
error_log( '[swiftly] inventory sync skipped — lock held' );
return;
}
set_transient( LOCK, time(), WINDOW );
try {
$response = wp_remote_get( 'https://api.example.com/inventory', [
'timeout' => 10,
] );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( $response->get_error_message() );
}
$items = json_decode( wp_remote_retrieve_body( $response ), true, 512, JSON_THROW_ON_ERROR );
update_option( 'swiftly_inventory_cache', $items, false );
} catch ( \Throwable $e ) {
error_log( '[swiftly] inventory sync failed: ' . $e->getMessage() );
} finally {
delete_transient( LOCK );
}
}
Four things this does that the tutorial version doesn’t:
One, it schedules the event only on activation, not inside a hook that runs on every request. The common mistake — wrapping wp_schedule_event() in if ( ! wp_next_scheduled() ) inside init — still pings the options table on every single load. Move it to activation and the overhead disappears.
Two, it uses a transient lock. If an earlier run of the same job is still executing when the next interval fires, the second run exits immediately instead of racing. The first time you import 5,000 WooCommerce products on an hourly schedule, you’ll understand why this matters.
Three, it uses try/finally to guarantee the lock releases even when the API call throws. PHP 8 exceptions in WordPress cron are a silent killer — if your callback throws an uncaught error, WP-Cron swallows it and marks the event complete. Catch explicitly.
Four, it uses update_option( $key, $value, false ) — the third argument disables autoloading, which means the cached inventory isn’t loaded into memory on every single request. A cron job that fills wp_options with autoloaded data is how “harmless” sync scripts end up adding 2MB to every page load.
When Action Scheduler beats wp_schedule_event hands down
WooCommerce bundles Action Scheduler for a reason: wp_schedule_event() cannot process a queue. It fires one hook at a time. If your job is “email every customer who ordered in the last 24 hours,” WP-Cron is the wrong tool — one slow email handler blocks every other scheduled event for the entire interval.
Action Scheduler is a proper background queue. It batches 25 actions per run, monitors memory usage, and spawns a loopback continuation when 90% of PHP memory is used or 30 seconds of execution have elapsed. It also persists jobs in a dedicated set of tables (wp_actionscheduler_actions in classic WooCommerce, or HPOS-aware tables in WooCommerce 8.2+), which means you get full auditability — every job has a claim ID, a status, a logged run history, and retry metadata.
If you’re on WooCommerce or installing a plugin that ships Action Scheduler, you already have it. Use it like this for a custom batch job:
<?php
namespace Swiftly\BatchEmail;
const HOOK = 'swiftly_send_reminder_email';
// Queue one action per customer, not one action for "all customers".
function queue_reminders( array $customer_ids ): void {
foreach ( $customer_ids as $customer_id ) {
as_enqueue_async_action(
HOOK,
[ 'customer_id' => $customer_id ],
'swiftly-reminders' // group — used for bulk cancel and monitoring
);
}
}
add_action( HOOK, function ( int $customer_id ): void {
$customer = new \WC_Customer( $customer_id );
if ( ! $customer->get_email() ) {
return;
}
wp_mail(
$customer->get_email(),
'We saved your cart',
'Come finish checking out.'
);
}, 10, 1 );
Two things to note. First, as_enqueue_async_action() runs as fast as possible on the next queue processing — no delay. If you want to schedule for a specific time, use as_schedule_single_action( $timestamp, $hook, $args, $group ). Second, always pass a group name. Groups let you cancel or monitor a whole batch with one call (as_unschedule_all_actions( HOOK, [], 'swiftly-reminders' )), which is the kind of operational tool you’ll wish you had at 2am when a bad deploy enqueues 40,000 broken jobs.
WooCommerce HPOS note for 2026: Action Scheduler’s own tables are not part of HPOS, but if your callback touches orders, you must use the HPOS-compatible CRUD API (wc_get_order(), $order->save()). Direct $wpdb queries against wp_posts for orders will silently do nothing on an HPOS-enabled store. This gotcha has bitten half the migrations we’ve worked on.
The WP-CLI workflow for cron in production
Most developers never discover that WP-CLI has a full cron toolkit. It’s the fastest way to diagnose, test, and manually trigger events on a live site without touching the dashboard. Keep this list pinned.
# What's scheduled and when it will run
wp cron event list
# Manually fire a specific event right now (bypasses the schedule)
wp cron event run swiftly_sync_remote_inventory
# Fire everything that's due, in order
wp cron event run --due-now
# Delete a stuck event
wp cron event delete swiftly_sync_remote_inventory
# Schedule a one-off event for testing
wp cron event schedule swiftly_sync_remote_inventory now
# Check if WP-Cron is being called at all
wp cron test
wp cron event run is the killer feature. It executes the hook exactly the way WordPress core would, with the same argument handling and error reporting — which means you can develop and debug a scheduled job without waiting for the interval or fiddling with time() offsets. Combine it with --debug and you’ll see every warning PHP throws, none of which appear in the normal cron path because WP-Cron suppresses notices.
For Action Scheduler, there’s a parallel set of commands once the plugin is active:
wp action-scheduler run --batches=1
wp action-scheduler list
Add wp cron event list to your deployment smoke test. It takes one second, and it’ll catch the moment a plugin update accidentally unschedules a critical hook.
Monitoring and alerts for missed jobs
Here’s the dirty truth most guides skip: setting up cron is easy, knowing it stopped working is hard. You’ll ship the fix, forget about it, and discover six months later that your backup hasn’t run since March because a single fatal error in an unrelated plugin took out the entire scheduler on cron pass.
Three tools solve this and you should set up at least two of them.
In-WordPress health check. Add a simple heartbeat that writes the current timestamp to an option on every successful run. Then have an external uptime service (UptimeRobot, Better Stack, or a plain shell script) hit a small JSON endpoint that returns that timestamp. If the timestamp is older than your interval plus a grace window, page someone.
add_action( HOOK, function (): void {
update_option( 'swiftly_cron_last_run', time(), false );
}, 1 );
add_action( 'rest_api_init', function (): void {
register_rest_route( 'swiftly/v1', '/cron-health', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function () {
$last = (int) get_option( 'swiftly_cron_last_run', 0 );
$age = time() - $last;
return new \WP_REST_Response(
[ 'last_run' => $last, 'age_seconds' => $age ],
$age < 900 ? 200 : 503
);
},
] );
} );
Action Scheduler logging. If you’re using Action Scheduler, every action writes a log entry. Hook into action_scheduler_logging_enabled and pipe failures to wherever you already collect logs (Sentry, LogRocket, plain error_log). Don’t rely on the WooCommerce > Status > Scheduled Actions screen to notice problems — nobody looks at it until a customer complains.
WP Crontrol for spot checks. Install it on staging and development, not production. In production it adds an admin page that most client sites don’t need, and the data is better seen via WP-CLI anyway.
The one case where you should leave WP-Cron alone
Most of this article has been about moving away from the default behavior, so here’s the honest counterpoint: if you run a small blog with a handful of scheduled posts, no WooCommerce, no plugins doing background imports, and fewer than a thousand daily visits — leave WP-Cron on. The traffic tax is negligible, the setup overhead of a real cron isn’t worth it, and the “missed events” failure mode rarely triggers for blogs that only schedule a post or two per week.
The moment you add any of the following, flip the switch: WooCommerce, a membership plugin, a backup plugin that runs more than daily, an email marketing integration, a bulk importer, or a caching plugin with a hit ratio above 80%. Those are the triggers that turn WP-Cron from invisible into a problem.
What to do now
If this post is describing a site you’re already responsible for, here’s the priority order. First, run wp cron event list and find out whether you currently have slipped events. If you do, that’s your real problem — not performance. Second, disable WP-Cron and set up a system cron using the PHP-CLI method above, not wget. Third, audit your custom scheduled tasks for the four mistakes in the example plugin: schedule-on-every-load, missing locks, uncaught exceptions, and autoloaded options. Fourth, if anything in your plugin stack is processing queues (email, imports, API sync), move it to Action Scheduler. Fifth, add a heartbeat endpoint so you actually find out when it breaks.
None of this is glamorous work. It’s the kind of infrastructure that pays off silently for years once it’s done right — and causes 4am incidents when it isn’t.
If you’d rather have this done properly by experts, our WordPress Speed & Performance Optimization service starts at $499 and includes a full Core Web Vitals audit along with a cron reliability review.