WP-Cron Is Not Broken, You Just Have No Traffic
The scheduler runs on page views, so a quiet site runs nothing. A reproduction of a missed event, the DISABLE_WP_CRON trap, and the cron entry that fixes it.
Martin Kiel · @mkiel
WordPress internals
- Topic
- INFRA
- Section
- Hosting & Infrastructure
- Published
- 16 OCT 2026
- Reading time
- 8 MIN
- Words
- 1929W
The ticket says the nightly report has not run for four days. The site is a brochure with a contact form, a few hundred visits a week, and one plugin that sends a digest. Nothing is broken in the plugin, and nothing is broken in WordPress. The event was scheduled for the right minute and no request arrived after that minute to notice it.
I reproduced exactly that locally on WordPress 7.1.3 with PHP 8.5.6 and the built-in PHP server.
One event was scheduled 45 seconds into the future. No page requests were made, and a shell poll of
the serialised cron option, taken 100 seconds later, showed the event still queued and
58 seconds overdue. Nothing had run. One HTTP request to the front page later, the
event fired and disappeared from the array:
scheduled_for=2026-10-16 21:22:16 (overdue by 58 s) # poll at 21:23:14
2026-10-16T21:23:18+00:00 wpsifero_nightly_report ran # after one page request
That is the whole diagnosis for the quiet-site case. WP-Cron has no clock. It has a queue and a trigger, and the trigger is somebody loading a page.
There is an article on this site about WP-Cron under heavy traffic, where the problem is the opposite: runs overlap, hooks fire twice, and the fix is locking. This piece is about a scheduler that does not fire at all. If your events are late by seconds on a busy store, read that one. If they are late by days, you are here.
What actually happens on a page view
Scheduled events live in one autoloaded row in wp_options, under the option name
cron, as an array keyed by Unix timestamp. On every request, wp_cron()
registers _wp_cron() on the shutdown action, which is late enough that
the HTML has already been flushed to the browser. That callback asks
wp_get_ready_cron_jobs() for timestamps that have already passed. If the answer is
empty, it returns and the request is over. If the answer is not empty, it makes a non-blocking
loopback POST to wp-cron.php and lets the real work happen in that second request.
The shutdown hook placement is worth noting because it changed. On WordPress 6.9
and newer the spawn happens at shutdown, so the visitor's page does not wait for it. Before that it
happened at wp_loaded, which meant a slow loopback could show up in your time to first
byte. I checked the 7.1.3 source and wp_cron() adds the callback to
shutdown unless ALTERNATE_WP_CRON is on, in which case the work runs
inside the visitor's own request.
Neither branch runs when there is no visitor. That is not a bug, it is the design, and it is
documented in the file itself: wp-cron.php opens by saying it is triggered when the
site receives a visit and can be called directly or by a system daemon when visits are not enough.
The mechanism is honest. The scheduling code people write on top of it is what assumes otherwise.
Three faults that look like one
Before changing anything, decide which of these you have, because the fixes do not overlap.
| Symptom in the event list | Actual fault | Fix |
|---|---|---|
| Events overdue by hours, all of them, site traffic low | Nothing triggers the scheduler | External trigger from system cron |
Everything overdue, traffic normal, wp cron test fails |
The loopback request is blocked | External trigger, then fix the host policy |
| Nothing overdue, nothing runs | DISABLE_WP_CRON is set with no replacement |
Add the missing cron entry |
The list that tells them apart is a single command. Run it from the WordPress root and read the relative times, not the absolute ones:
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron test
On my quiet lab install the list showed every core event clustered an hour or more in the
future, because the only runs that had happened were the ones a page view had triggered. A real
site with a stuck queue looks the same but with negative-relative times that keep growing.
wp cron test answers the second row: if the host blocks PHP from making an HTTP request
to itself, the test fails and the events stay in the array forever, because the spawn returns false
and nothing retries until the next page view.
Turning the trigger off is the part people get wrong
The standard advice is to stop letting visitors trigger cron and call it from a real scheduler.
The constant goes in wp-config.php above the wp-settings.php line:
define( 'DISABLE_WP_CRON', true );
With that defined, _wp_cron() returns immediately, so no page view spawns a runner
and no page view runs the work inline. The important half of that sentence is the second half.
wp-cron.php itself is unaffected: it never checks the constant, because the file is
what a system scheduler is supposed to call. I verified the difference on the local install. With
the constant set and a job due five minutes in the past, a page request did nothing and the event
stayed queued and overdue by 312 seconds. Calling wp-cron.php directly through the web
server, the way a cron entry would, ran the job:
queued for 2026-10-16 21:18:49, overdue by 312 s
wp-cron.php http=200
2026-10-16T21:24:13+00:00 wpsifero_stock_sync ran
One detail in that transcript is easy to get wrong when you test it by hand. When you request
wp-cron.php with a doing_wp_cron query argument, core compares it against
the doing_cron transient and returns early if they disagree. A bare request sets the
lock itself, and my first manual call returned 200 with nothing executed because a lock from an
earlier run was still inside the one-minute WP_CRON_LOCK_TIMEOUT window. If you are
testing with curl, wait out the minute or pass the current lock value. If you are testing with a
real cron daemon, you never see this, because the scheduler and the URL are in step.
The two-line trap
DISABLE_WP_CRON without a cron entry is the most common self-inflicted version of
this outage. It is applied by a host migration checklist, the cron entry is not, and the site
looks healthy for a week. If you grep your wp-config.php for the constant, grep the
server's crontab in the same breath.
The cron entry, and the two forms worth using
On a cPanel host the Cron Jobs screen accepts a command and runs it as the account. The PHP
binary must be named explicitly, because the scheduler's PATH is not your shell's:
*/5 * * * * /usr/local/bin/php /home/example/public_html/wp-cron.php >/dev/null 2>&1
That works and it is the form most hosts document. I prefer the WP-CLI form when the host has it, because it goes through the same bootstrap as every other command and takes the site explicitly, which matters on multisite and on any box where the document root is not the working directory:
*/5 * * * * /usr/local/bin/wp --path=/home/example/public_html --url=https://example.com cron event run --due-now >/dev/null 2>&1
Five minutes is a decision, not a default. Any event you schedule more often than the trigger interval will accumulate as due timestamps and run in a burst when the trigger finally arrives. On this lab install I watched the burst happen from the other side: after the event sat overdue for nearly a minute, the single page request that unblocked it took 1.1 seconds to return 888 KB, because the visitor's request was the thing that spawned the runner. On a real site with a dozen overdue jobs that cost lands on a real person's page load. If you keep the trigger at five minutes, audit every schedule shorter than five minutes and either lengthen it or accept the burst.
Why a scheduled post can publish hours late
The visible form of this failure is a post that stays in the "Scheduled" state past its publish
time. WordPress creates a one-off event, publish_future_post, for the moment the post
is due. If no request arrives after that moment, the event stays in the array and the post keeps
future status. Visit the admin and the Posts list stops being polite about it: the
status column prints Missed schedule in red. I checked the 7.1.3 admin code and
that string is produced by the posts list table whenever a post has future status and
its timestamp is in the past.
Nothing is wrong with the post. Its status is future, its date is in the past, and
the event that would have flipped it is still queued. If you then publish it by hand, you have
fixed the symptom and left the cause for the next post. Two other consequences follow from the same
delay. A post published from the future has no publish action fired until it actually
runs, so anything hooked to that action (pings, sitemap pings, cache warmers, newsletter triggers)
is late by the same amount. And a post that is rescheduled or edited while the event is stale can
leave the old event in the array, so the eventual run fires a publish for a post that has since
changed. Check wp cron event list for a publish_future_post entry before
you go looking for a broken post.
What the fix does not solve
An external trigger removes the traffic dependency. It does not make WordPress a scheduler, and
three properties of WP-Cron survive it. A job that dies halfway is simply gone; there is no retry,
because the event was unscheduled before the callback ran. Jobs that share a trigger interval run
in the same PHP process, so a slow one delays the others and can hit
max_execution_time with the rest of the batch unrun. And the schedule is stored in a
single option that any plugin can rewrite, so a bug in an unrelated plugin can empty the queue
without a trace.
That last one has a practical consequence for anything with a deadline. If the work must happen at a specific time, on a specific date, and must be retried when it fails, the answer is a durable queue rather than a cron event. Action Scheduler, which ships with WooCommerce, stores jobs as rows in a table, claims them atomically, and retries failures with a backoff. Its own runner is a WP-Cron event by default, which puts you back where you started unless you run the queue from the system cron. I use it for order timeouts and subscription renewals, and I keep ordinary WP-Cron for reports, cleanups and update checks, where running an hour late costs nothing.
One limitation of everything above: it cannot detect a job that ran and failed silently. An external trigger makes the event run on time and the event then disappears whether or not the work succeeded. The only defence is a record written by the job itself, which is why the digest plugin in the first paragraph could tell us it had not run for four days. Log the success from inside the callback, not from the scheduler.
What to do on Monday
- Run
wp cron event list --fields=hook,next_run_relative,recurrence. Everything overdue on a low-traffic site is the traffic problem, in one line. - Run
wp cron testwhile the constant is still unset. OnceDISABLE_WP_CRONis defined the command reports a failure by design, and you will mistake it for a host problem. - Define
DISABLE_WP_CRONand add one cron entry in the same change set. Never ship the constant alone. - Set the interval to something you can defend, then lengthen every plugin schedule shorter than it.
- Add a log line inside the callbacks that matter, so "it did not run" is a fact you can establish rather than a suspicion.
- Move deadline work to a durable queue and run that queue from the system cron.
Martin Kiel · @mkiel
WordPress internals
Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.
Continue