Skip to content
System nominalUTC --:--:--Articles 080

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
Technical cover graphic for WP-Cron Is Not Broken, You Just Have No Traffic
Generated schematic — INFRA series, REV 2.6.

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 listActual faultFix
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

  1. 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.
  2. Run wp cron test while the constant is still unset. Once DISABLE_WP_CRON is defined the command reports a failure by design, and you will mistake it for a host problem.
  3. Define DISABLE_WP_CRON and add one cron entry in the same change set. Never ship the constant alone.
  4. Set the interval to something you can defend, then lengthen every plugin schedule shorter than it.
  5. Add a log line inside the callbacks that matter, so "it did not run" is a fact you can establish rather than a suspicion.
  6. 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

Related in Hosting & Infrastructure