WordPress wp-cron: why it fails and how to replace it

WP-Cron looks like a cron daemon but it is not one. It is a PHP routine that only runs when a visitor hits the site, which is exactly why scheduled posts go missing, backups skip their window, and WooCommerce emails arrive hours late. This article explains what WP-Cron actually is, why it fails, and how to replace it with a real system cron so scheduled work runs when it is supposed to.

If your scheduled posts are missing, backups skip their window, or WooCommerce order emails turn up hours late, the problem is almost always that WP-Cron is not running the way people assume it does. WP-Cron is not a background process. It is a PHP routine that only fires when a visitor lands on the site, and it has five well-documented failure modes that together account for almost every "wp-cron not working" report.

What WP-Cron actually is

WP-Cron is a pseudo-scheduler built into WordPress core. It has nothing in common with the Unix cron daemon beyond the name.

Every time a page loads on a WordPress site (not every admin-ajax call, but any normal front-end or admin request) WordPress bootstraps and calls spawn_cron(). That function reads the cron option out of wp_options, sees which events are due, checks a transient called doing_cron to make sure another cron run did not start within the last WP_CRON_LOCK_TIMEOUT seconds (60 seconds by default), and if events are due and the lock is free, fires a non-blocking HTTP POST to wp-cron.php on the same host. The HTTP request carries a doing_wp_cron query parameter with a 22-digit microtime value that serves as the lock key. The wp-cron.php process then runs whatever action hooks are due and exits. This whole mechanism is documented in the Plugin Handbook.

Two implications follow directly.

First, no page loads means no cron runs. If your site is scheduled to publish a post at 09:00 and the first visitor arrives at 11:30, the post publishes at 11:30. The Plugin Handbook says this in plain language: scheduling errors can happen "if you schedule a task for 2:00PM and no page loads occur until 5:00PM". This is by design, not a bug.

Second, the loopback HTTP request is a critical path. If anything blocks the server from making an HTTP request back to itself, the cron run never happens. The visitor's page load still completes normally. There is no visible error. Scheduled events just accumulate silently.

WordPress ships with four default schedules: hourly, twicedaily, daily, and (since WordPress 5.4) weekly. Plugins can register custom intervals through the cron_schedules filter hook.

What depends on WP-Cron in a normal WordPress site

A lot more than most people realize. Every one of these features stops working when WP-Cron is broken:

  • Scheduled post publishing
  • Plugin and theme auto-update checks
  • Transient expiry cleanup
  • Every call to wp_schedule_event() from any plugin: backup plugins, email newsletter senders, SEO sitemap rebuilds, security scanners, cache warmers
  • WooCommerce order emails and stock syncs, because Action Scheduler is layered on top of WP-Cron

A note specifically about WooCommerce. Action Scheduler was bundled into WooCommerce core starting with WooCommerce 4.0, and on every WooCommerce site since then you have two cron systems stacked: WordPress core's native WP-Cron plus Action Scheduler's own queue runner. Action Scheduler does not depend hard on WP-Cron (it can be triggered directly via the action_scheduler_run_queue hook), but it hooks into WP-Cron by default on non-admin traffic. If WP-Cron stops, your order emails, subscription renewals, and stock sync jobs stop with it. Runaway cron jobs can also trigger self-inflicted 429 Too Many Requests from external API rate limits.

The five failure modes that cover almost every report

Across thousands of "wp-cron not working" reports, the underlying cause is almost always one of these five.

1. No traffic or heavy full-page caching

No page load means no spawn_cron() call. This hits staging sites, low-traffic blogs, and sites sitting behind aggressive full-page caching (a Varnish layer, an nginx fastcgi_cache, an LSCache or WP Rocket disk cache, Cloudflare Cache Reserve). A request served from a reverse-proxy cache never reaches PHP, which means WordPress never bootstraps, which means no cron spawn. A cached site can have scheduled events that do not fire for days until a logged-in admin visit punches through the cache.

2. The loopback request is blocked

This is the most technically subtle failure and probably the most common on managed hosting. The non-blocking HTTP POST that spawn_cron() fires to wp-cron.php fails silently. Anything on the network path between the PHP process and the webserver on the same host can cause it. The Advanced Administration Handbook on loopbacks lists the usual suspects, and WP Crontrol has a cURL error table for the common failure codes. The concrete causes I see most often:

  • BasicAuth covering the entire site, including wp-cron.php, returns HTTP 401 to the loopback
  • A firewall or security plugin blocks "server to itself" requests by default
  • Cloudflare or a CDN bot-rule blocks requests coming from the origin IP
  • SSL misconfiguration produces cURL error 35 (TLS handshake failure) on the loopback
  • DNS resolution fails on the origin, giving cURL error 6
  • wp-cron.php is blocked at the webserver level via .htaccess or nginx rules (HTTP 403)
  • wp-cron.php has been deleted or renamed to "disable cron" without setting DISABLE_WP_CRON, giving HTTP 404
  • A fatal PHP error in a plugin or theme crashes the wp-cron.php request with HTTP 500

3. DISABLE_WP_CRON is set without a replacement runner

define( 'DISABLE_WP_CRON', true ); in wp-config.php stops spawn_cron() from ever firing. The constant has been in core as a long-standing configuration option. It does not delete scheduled events: the cron option in wp_options is untouched, wp-cron.php still works if you call it directly, plugins can still register future events. The constant only removes the traffic-triggered trigger.

This is the single most common cause on managed hosting. A lot of managed WordPress hosts (WP Engine, Cloudways, Kinsta, SpinupWP) set DISABLE_WP_CRON in their base wp-config.php template and replace it with their own runner. If that runner breaks, or if the site is migrated to another host and the runner is left behind, nothing triggers WP-Cron and every scheduled job silently stops. The site looks fine. Scheduled posts do not publish.

4. Long-running events causing lock contention

WP_CRON_LOCK_TIMEOUT defaults to 60 seconds. If a single cron event (a large backup, a multi-megabyte feed import, a site-wide regeneration task) runs for longer than that, the transient lock expires. Subsequent page loads may spawn new cron processes while the original is still running, and the two runs may fight over the same event. In the other direction, a slow event can hold the lock long enough that other due events miss their window. WP Crontrol's documentation on missed events describes this pattern.

5. Race conditions on high-traffic sites

Multiple simultaneous page loads can each call spawn_cron() before the transient lock is set by the first one. All of them spawn separate wp-cron.php processes. All of the processes may attempt to run the same due events. The lock mechanism is not perfectly atomic, especially across a multi-server setup where several PHP-FPM hosts share a database but not local state. This is an acknowledged limitation, tracked in WordPress Trac ticket #57924.

Diagnosing a broken WP-Cron

There are three non-destructive ways to tell whether WP-Cron is running, in increasing order of authority.

Site Health. In the WordPress admin, open Tools, then Site Health, then the Info tab, then "Scheduled events". This runs a loopback test and reports whether spawn_cron() can reach wp-cron.php. If it cannot, Site Health reports the cURL error directly.

WP Crontrol. The WP Crontrol plugin lists every registered event, the next scheduled run, and flags any event whose scheduled time has passed. If you see events in the past, the runner is not firing.

WP-CLI, the authoritative check. Over SSH on any host with WP-CLI installed:

# Run this from the site root
wp cron test                       # tests whether the spawn mechanism works
wp cron event list --fields=hook,next_run,recurrence  # see all events and their next run time
wp cron event list --due-now       # show only overdue events

wp cron test gives you a clean pass or fail and the exact error string if it fails. It is the single fastest way to get a definitive answer. The full command reference lives in the WP-CLI cron command documentation.

Three things often get confused with broken WP-Cron and are worth ruling out before you start fixing things:

  • A "Missed Schedule" status on a post does not mean the server was down. It means the cron runner did not fire at the scheduled time. The site can be serving traffic normally while events silently pile up.
  • A slow scheduled job is not the same as a missing one. If the job runs but takes five minutes, that is a different problem (see below on the performance cost).
  • A plugin that uses its own scheduler (like Action Scheduler on WooCommerce, or the async task runner in some membership plugins) can appear to be stuck even when WP-Cron is fine. Check the plugin's own queue first.

The performance cost on busy sites

On busy sites, the default WP-Cron behaviour has a measurable cost. The non-blocking loopback POST is supposed to add zero latency to the visitor's page load, and the original comment in the wp-cron.php header claims exactly that. WordPress Trac ticket #18738 acknowledges that the claim is not true in practice. In environments where initiating the loopback takes time (TLS handshake, DNS lookup, firewall inspection, a slow reverse proxy), the visitor whose request triggered the cron spawn pays that time as extra TTFB.

PHP-FPM mitigates this through fastcgi_finish_request(), which flushes the response to the client before the PHP process finishes. On FPM-backed sites this keeps the visible latency low. On non-FPM setups, or where the FPM finish-request optimization is not in play, the cron spawn can show up in the site's TTFB numbers as a recurring spike.

There is a second, related cost. Every request that triggers cron competes for the same PHP worker pool that is serving traffic. On a small pool, a long-running cron job can tie up a worker and push concurrent traffic into the queue. This is often what people mean when they report that the site feels slow for no reason every few hours or that CPU usage has a baseline that does not match traffic.

Moving WP-Cron off the request path solves both problems. It also fixes the reliability issues from the five failure modes above, because a system cron is immune to traffic volume, loopback blocking, lock contention, and race conditions by design.

Replacing WP-Cron with a real system cron

The recommended approach has two steps. Disable the traffic-triggered trigger, then add an external runner that calls the WordPress cron code at a fixed interval.

Step 1: disable the traffic-triggered trigger

Open wp-config.php through your hosting panel's file manager (or download it via SFTP) and add this line anywhere above the "/* That's all, stop editing! */" comment:

// Stop wp-cron from firing on every page load.
// Requires an external runner to be configured (see below).
define( 'DISABLE_WP_CRON', true );

The line does nothing destructive. It just stops spawn_cron() from running on every front-end request. Scheduled events stay in the database. Until you add an external runner, nothing will run them.

Step 2: add a cron job through your hosting panel

In cPanel (or your host's equivalent panel), open "Cron Jobs", pick "Every minute" (or "Common Settings, Once per five minutes" if you prefer), and set the command to:

wget -q -O /dev/null "https://yoursite.nl/wp-cron.php?doing_wp_cron" > /dev/null 2>&1

Most shared hosts do not give you WP-CLI over cron, so wget is the realistic option. If the host's loopback is sane (no BasicAuth, no bot rule blocking the origin IP), this works reliably. If the host has locked the loopback down, contact support and ask them to whitelist wp-cron.php for origin requests.

This approach is documented in the Plugin Handbook's guide to hooking WP-Cron into the system task scheduler.

  • Why every minute? WordPress's built-in intervals start at hourly, but plugins can register sub-hour intervals (five-minute newsletter sends, ten-minute feed pulls). Running the cron every minute guarantees the shortest registered interval is respected. If you know your site only has hourly or longer events, every five minutes is fine.

If you have SSH access: a Linux crontab entry with WP-CLI

On a Linux host with WP-CLI installed, you can skip the HTTP round-trip entirely. Add a single line to the web server user's crontab (usually www-data on Debian and Ubuntu, nginx or apache on Red Hat family, or a dedicated site user on managed hosts). Edit the crontab with crontab -e as that user and append:

* * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/yoursite.nl/htdocs --quiet > /dev/null 2>&1

Field by field, this is the standard Linux crontab(5) five-field format: minute, hour, day of month, month, day of week. * * * * * means "every minute, every hour, every day, every month, every day of the week", which is every minute.

  • Why WP-CLI over wget? WP-CLI bypasses HTTP entirely. It runs the WordPress bootstrap directly in the PHP CLI context, which eliminates the loopback failure class completely. No BasicAuth, no firewall, no TLS handshake. On managed hosts where the loopback has been the problem, this alone is the fix.
  • Why the --quiet flag? Cron mails the output of each run to the system user. Without --quiet, you get a mail every minute. With it, only errors reach you.
  • Why > /dev/null 2>&1? Redirecting stdout and stderr to /dev/null is belt-and-braces suppression on top of --quiet, making sure cron does not queue up an unread mailbox on systems where the cron mailer is misconfigured.

Verifying the replacement works

Do not assume it works because the cron job was accepted. Verify.

Check a scheduled post. Schedule a test post for two minutes from now in wp-admin. Wait. Refresh the front page. If the post appeared at the expected time, the system cron is firing on schedule. This is the simplest and most convincing check.

Check Action Scheduler, if WooCommerce is installed. In wp-admin, go to Tools, then "Scheduled Actions". Sort by "Scheduled" and look for rows with a date in the past. If new rows keep moving into "Completed" after your cron job went live, Action Scheduler is catching up through the WP-Cron path you just restored.

Watch TTFB on front-end requests. If the recurring TTFB spike you had every few minutes disappears after the cron is moved off the request path, the performance side of the fix is working.

If you have SSH access: run wp cron event list --due-now from the site root, wait two minutes, and run it again. Overdue events should be gone (or the list should be shorter). If nothing has moved, the cron job is not running what you think it is running. You can also check the cron mail or log: on Linux, cron delivers output to the local user mailbox (read it with mail or less /var/mail/www-data). If the mailbox is empty and --quiet is in the command, that is a good sign.

What this does not fix

Moving WP-Cron to a system cron does not fix:

  • Plugins that schedule failed events in a tight loop (the events will still pile up, they just pile up under a reliable runner instead of a broken one)
  • Slow cron events that take minutes to run (you need to optimize or offload the event itself, not the runner)
  • Action Scheduler jobs that fail because of unrelated errors (database deadlocks, third-party API timeouts, PHP memory exhaustion in the job itself)
  • Cache warmer plugins that depend on traffic to hit URLs (they still need traffic, cron only runs their housekeeping)

If you disabled WP-Cron but scheduled posts still go missing after a full system cron is in place, the problem is not WP-Cron any more. Check the PHP error log for fatals during the cron run, look at the "Briefly unavailable for scheduled maintenance" article if updates are crashing inside cron, and check for plugins that wipe or rewrite the cron option in wp_options during their own housekeeping.

The misconceptions worth naming out loud

Three things come up in every "wp-cron not working" thread, and all three are wrong:

  • "WP-Cron fires at exact times." It does not. It fires on page loads, using "next opportunity" semantics. Even with a system cron running every minute, a job scheduled for 09:00:00 runs at the first minute mark after 09:00, which might be 09:00:47.
  • "Disabling WP-Cron breaks scheduled posts permanently." It does not. The scheduled events are in the database and stay there. Disabling WP-Cron only removes the trigger. A system cron picks them up the moment it is in place.
  • "A missed schedule means the server is down." It does not. The server can be serving thousands of requests per minute while the cron runner quietly fails to fire. "Missed schedule" is a runner problem, not a server problem.

Summary

WP-Cron is a traffic-triggered PHP routine, not a daemon, and that single fact explains every reliability issue it has. The five failure modes (no traffic, blocked loopback, DISABLE_WP_CRON without a runner, long-running events, race conditions) cover almost every report. The reliable fix is to turn off the traffic-triggered trigger with DISABLE_WP_CRON and add a system cron that runs wp cron event run --due-now every minute. On cPanel hosts without WP-CLI, a wget hit against wp-cron.php from a cPanel cron job is the fallback. Verify with wp cron event list --due-now, a test scheduled post, and (on WooCommerce) the Action Scheduler screen.

Want WordPress hosting that stays stable?

I handle updates, backups, and security, and keep performance steady—so outages and slowdowns don't keep coming back.

Explore WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.