WordPress Cron Not Working: Find the Cause, Then Fix It
On this page, 9 sections
WordPress cron stops running when nothing triggers it, when the request it sends to itself never arrives, or when DISABLE_WP_CRON is set and no system job calls wp-cron.php instead. WP-Cron is not a clock. It is a check that happens when someone loads a page, and the most reliable fix is to turn that check off and run a real scheduler.
I read the WordPress developer docs and the core source (wp-cron.php, spawn_cron(), _wp_cron(), the Site Health tests) on 11 October 2026, from the wordpress-develop trunk, which was at version 7.2-alpha-63166. This post follows what that code does and says so where I am inferring.
I did not debug a broken live site for this post. The code snippets were syntax-checked (php -l and bash -n), not run against a production server. If you want the narrower symptom of a scheduled post that never went out, start with missed schedule and WP-Cron.
What WP-Cron actually is
The header comment in wp-cron.php says it directly: WP-Cron is triggered when the site receives a visit. The Plugin Handbook’s cron page says the same and gives the consequence. If you schedule a task for 2:00 PM and no page loads until 5:00 PM, it runs at 5:00 PM. Unlike system cron, a missed task is not skipped; it runs at the next page load.
In core, the trigger is a hook. default-filters.php adds wp_cron to init unless DOING_CRON is already defined.
In the trunk I read, wp_cron() moves its callback from wp_loaded to shutdown (the source marks this as a change in 6.9.0, and keeps the older hook only for ALTERNATE_WP_CRON). So on a current version, the check runs at the end of a request, after the visitor has been served.
That callback, _wp_cron(), returns early in two cases that matter for this post: when the request URI contains /wp-cron.php, and when DISABLE_WP_CRON is truthy. Everything below follows from those two lines and from spawn_cron().
How a cron run starts, step by step
When the check finds work to do, spawn_cron() decides whether to start a run. Reading the function top to bottom, it gives up silently at each of these points:
- The request already is a cron request (
DOING_CRONis defined, ordoing_wp_cronis in the query string). - The
doing_crontransient holds a lock younger thanWP_CRON_LOCK_TIMEOUT. The constant defaults toMINUTE_IN_SECONDS, 60 seconds, and the code comment says the purpose is to avoid running “more than once every 60 sec”. - There are no ready events, or the earliest one is still in the future.
- With
ALTERNATE_WP_CRON, the request is not a plain GET, or it is an AJAX or XML-RPC request. This mode redirects the visitor instead of looping back.
If it passes all of that, it writes the lock into the doing_cron transient and then sends a request to its own site: wp_remote_post() to site_url( 'wp-cron.php' ) with doing_wp_cron and a timestamp in the query string. The arguments are a timeout of 0.01 seconds and blocking set to false.
A source comment admits that wp_remote_post() may not honour that timeout, and that it may take about a second. The request can be altered through the cron_request filter, and SSL verification for it goes through https_local_ssl_verify.
That second request is where wp-cron.php does the real work. It exits if the request carries POST data, is an AJAX call, or DOING_CRON is already defined, takes the same lock, defines DOING_CRON, and then runs each due hook.
For every event it reschedules or unschedules first, then fires the hook, so one failing callback does not stop the event from being rescheduled. It checks that no other process stole the lock, and deletes the transient at the end.
The takeaway: a cron run needs a WordPress request, a free lock, and a working HTTP request from your site to itself. Every “WordPress cron not working” case breaks one of the three.
Why it stops: the causes, in the order I would check them
1. DISABLE_WP_CRON is set and nothing replaced it
Because _wp_cron() returns when DISABLE_WP_CRON is truthy, a site with that constant and no system job never runs a scheduled event at all. Some hosts and some performance guides set it.
The core file spells out the relationship: defining the constant and calling wp-cron.php directly are separate, and the direct call does not depend on the constant. Open wp-config.php and search for it first. It takes ten seconds and it is the cleanest explanation.
2. The site cannot reach itself
The spawn is an HTTP request from the server to its own public address. If that address is blocked by a firewall, protected by HTTP basic authentication, pointed at the wrong site_url, or fails certificate checks, the request never reaches wp-cron.php.
Core’s Site Health has a loopback test for this, and the WP-CLI wp cron test command tries to spawn WP-Cron over HTTP and warns on a response that is not 200. Run the test, then read the warning rather than guessing.
3. No visits, or visits that never reach PHP
The handbook’s 2 PM and 5 PM example is the plain version: a quiet site runs events late. My inference, from the fact that the trigger sits inside WordPress’s own request lifecycle, is that a page served entirely from a full-page cache never boots WordPress and so triggers nothing.
I did not test this against a specific cache plugin, so treat it as a lead to check, not a finding.
4. A lock that does not clear
The lock is a transient. Per spawn_cron(), a lock that is more than ten minutes in the future is reset to zero, and any lock older than 60 seconds stops blocking. So a lock cannot hold forever by itself.
If a hook crashes mid-run, my reading is that the final delete never executes and the lock simply ages out after the timeout. A site that runs events only every few minutes may be explained by this, but if events never run, look at causes 1 and 2 first.
5. The event is late, not lost
Site Health separates the two. In the trunk I read, get_test_scheduled_events() reports “A scheduled event is late” and “A scheduled event has failed”, both as recommended improvements. It treats an event as late immediately past its time by default, and as missed after five minutes.
When DISABLE_WP_CRON is set, it relaxes those thresholds to 15 minutes and one hour, on the assumption that a system job is running less often. Look under Tools, Site Health, Status, and read which of the two you have.
Check before you change anything
If you have shell access and WP-CLI, three commands tell you most of it. They come from the WP-CLI command pages, which I read on 11 October 2026. wp cron test errors when DISABLE_WP_CRON is true, warns when ALTERNATE_WP_CRON is set, and otherwise attempts the spawn over HTTP.
wp --path=/var/www/example.com cron test
wp --path=/var/www/example.com cron event list --fields=hook,next_run_relative
wp --path=/var/www/example.com cron event run --due-now
The first checks the loopback. The second lists what is scheduled and when it is due. The third runs everything that is due right now, which tells you whether the hooks themselves work once something triggers them.
If the third command runs your events and the site never did on its own, the problem is the trigger, not the code. I checked these lines with bash -n; I did not run them against a live site. Replace the path with your own.
The fix that removes the whole class: a system cron
The handbook page “Hooking WP-Cron Into the System Task Scheduler” (last updated 20 March 2025) gives the procedure: add define( 'DISABLE_WP_CRON', true ); to wp-config.php, then have the operating system call wp-cron.php on a schedule. Its Linux example is a cron entry that calls the URL with wget --delete-after, shown as a daily midnight entry; the page uses */15 to illustrate every 15 minutes.
<?php
define( 'DISABLE_WP_CRON', true );
I prefer the WP-CLI route when I have shell access, because it does not depend on an HTTP request at all, so it also skips the loopback problem. The command is wp cron event run --due-now.
The WP-CLI page describes it as running all hooks due right now and says it respects the doing_cron transient to prevent overlapping runs. In crontab, the command part looks like this:
cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1
Put the standard five-field schedule in front of it. The handbook’s syntax example uses */15, which is 96 runs a day (24 hours times 60 minutes, divided by 15). Every five minutes is 288 runs a day.
Pick the interval from how late your most time-sensitive event can be, because that is the worst-case delay. I checked the command with bash -n; the crontab line itself I did not run through a cron daemon.
Two cautions. First, once DISABLE_WP_CRON is on, wp cron test will report an error by design, so do not read that as a new failure. Second, if the stuck jobs are from a plugin’s own queue and not WP-Cron events, the cause may sit elsewhere. I compare the two systems in WP-Cron vs Action Scheduler.
If you are also deciding between core and plugin features that may rely on scheduled work, ask whether the plugin needs a recurring job at all. I make that comparison for XML maps in core sitemaps vs a plugin.
What I could not verify
- Behaviour on any specific host or caching plugin. Whether a given page cache prevents the trigger is an inference from the code, not a test.
- Whether your host already runs a system job. Some hosts do. The code cannot tell you; check your control panel or ask the host.
- That the thresholds above are unchanged in the release you run. I read trunk. Compare against your version’s
class-wp-site-health.phpif the numbers matter.
Questions people ask
Does WP-Cron run if nobody visits the site?
Not on its own. The handbook says WP-Cron works by checking on page loads. With a system cron calling wp-cron.php or WP-CLI, the schedule no longer depends on visitors.
Is it safe to set DISABLE_WP_CRON?
Only if something else runs the events. The constant stops the page-load trigger; it does not replace it. Set it together with the system job, not before.
Why does wp cron test fail after I changed this?
Because it is written to error when DISABLE_WP_CRON is true. Use wp cron event list and watch the next-run times move instead.
Do this today
Search wp-config.php for DISABLE_WP_CRON. If it is there, confirm your host or you run a scheduled job against wp-cron.php or wp cron event run --due-now. If it is not there, run wp cron test and read the warning.
Resources
- Plugin Handbook, Cron, last updated 14 December 2023, read 11 October 2026
- Hooking WP-Cron Into the System Task Scheduler, last updated 20 March 2025, read 11 October 2026
- wp-includes/cron.php (spawn_cron, wp_cron), WordPress trunk, read 11 October 2026
- wp-cron.php, WordPress trunk, read 11 October 2026
- class-wp-site-health.php, WordPress trunk, read 11 October 2026
- WP-CLI: wp cron event run, read 11 October 2026
- WP-CLI: wp cron test, read 11 October 2026