A post is scheduled for 3am. At 9am it is still sitting in the posts list with a red Missed schedule beside it, and the author is fairly certain they did nothing wrong.
They didn’t. WordPress had no way of knowing that 3am had happened. Nothing on the server was watching the clock on its behalf.
WP-Cron is named after Unix cron, and that name is the source of most of the confusion. Unix cron is a daemon: a process that sits in memory, wakes on a timer, and runs commands whether or not anybody is using the machine. WP-Cron is a list of pending jobs in a database row. It has no process and no timer. It runs when a visitor makes it run.
Hold that fact steady and the missed post schedule stops being mysterious, and so do the backups that stopped happening, the plugin update notices that went stale, and the WooCommerce actions stuck at pending with a due date from last Tuesday. They are all the same failure.
What WP-Cron actually is
Every scheduled task in WordPress lives in a single autoloaded row in wp_options called cron. It is a PHP array keyed by Unix timestamp, and under each timestamp sits the hook name, its arguments, and, for repeating jobs, the interval. Core reads and writes it through _get_cron_array() and _set_cron_array() in wp-includes/cron.php. Plugins add to it with wp_schedule_event() and wp_schedule_single_event().
Simplified, the contents look roughly like this. The inner keys are md5( serialize( $args ) ), which is how core tells two otherwise identical events apart, and the version entry sits alongside the timestamps rather than inside them:
array(
1755500400 => array(
'publish_future_post' => array(
'9b2e...' => array(
'schedule' => false,
'args' => array( 4127 ),
),
),
),
1755504000 => array(
'wp_version_check' => array(
'd41d...' => array(
'schedule' => 'twicedaily',
'interval' => 43200,
'args' => array(),
),
),
),
'version' => 2,
)
That is the whole system. A timestamp, a hook, some arguments. The intervals available by default come from wp_get_schedules(): hourly, twicedaily, daily, and weekly, the last of which was added in WordPress 5.4. Plugins register their own through the cron_schedules filter.
Nothing in this list is a promise. It is a note that says “run this at or after 3:00am”. Something still has to read the note.
The loopback request that does the work
The thing that reads the note is an ordinary page load. Core hooks wp_cron() to init, so every front-end request, admin request and Ajax call passes through it. That function no longer does the work itself: it queues the private _wp_cron() on the shutdown action, so the check happens after WordPress has finished sending the page. _wp_cron() asks wp_get_ready_cron_jobs() whether anything is now due, and if something is, it calls spawn_cron().
spawn_cron() does not run the tasks either. It sends an HTTP request from the server back to the server (a loopback request) aimed at wp-cron.php in your WordPress root. The call goes out through wp_remote_post() with 'blocking' => false and a timeout of 0.01 seconds, so the visitor’s page does not wait for it and nobody ever sees the response. WordPress fires the request and forgets about it.
On the receiving end, wp-cron.php loads WordPress again, defines DOING_CRON, takes a lock stored in the doing_cron transient so two runs cannot overlap, collects everything that is due, and fires each hook with do_action_ref_array(). The lock is governed by WP_CRON_LOCK_TIMEOUT, which defaults to MINUTE_IN_SECONDS (60 seconds).
Two consequences follow directly, and between them they account for nearly every broken schedule.
First, no traffic means no trigger. A brochure site that gets forty visits a day, all of them between 9am and 6pm, will not run a single scheduled task overnight. The 3am post publishes at 9:12am, when the first visitor arrives.
Second, the server has to be able to make an HTTP request to itself, and a great many servers cannot. A firewall rule that blocks requests originating from the server’s own IP. A security plugin that treats self-requests as suspicious. HTTP basic auth on a staging site, which answers the loopback with 401. A host whose network does not let the site resolve its own domain name. A stale siteurl after a migration, since the loopback URL is built with site_url( 'wp-cron.php' ). A WAF challenge page served instead of PHP. Any of these stops every scheduled task on the site, permanently and silently. Because the request was non-blocking and the response was discarded, nothing is logged and nothing is shown. The tasks do not fail. They never start.
“Missed schedule” is a label, not a status
This part is worth getting precise, because the wording sends people looking for a database state that does not exist.
When you schedule a post, WordPress sets post_status to future and, via _future_post_hook() in wp-includes/post.php, calls wp_schedule_single_event() for the publish_future_post hook at the chosen timestamp. That is all that happens. There is no publishing machinery waiting.
The red text comes from the posts list table. In wp-admin/includes/class-wp-posts-list-table.php, the date column subtracts the post’s timestamp from the current time; if the status is still future and that difference is positive, it prints “Missed schedule” instead of “Scheduled”. The post’s status in the database is unchanged. Nothing was marked as failed. The admin screen is doing arithmetic and telling you the truth: this should have gone out by now, and it hasn’t.
Which is why the label so often vanishes the moment you look at it. Opening the dashboard is a page load; the page load fires the loopback; the loopback runs the overdue publish_future_post event; check_and_publish_future_post() sees a post whose time has come and publishes it while you are still reading the screen. The event was never lost, only unattended. The post keeps its original post_date, so it goes out back-dated, which is its own small problem if you care about feeds and social previews.
A schedule that stays missed, no matter how many times you reload, is a different story. That means either nothing is triggering cron at all, or the event itself is gone from the cron array, usually because a plugin, an import, or a hand-edited option overwrote it.
Everything else that stops at the same time
Missed post schedules are the visible symptom. They are rarely the expensive one. When the loopback is blocked, this is a partial list of what else has stopped:
- Update checks. Core schedules
wp_version_check,wp_update_pluginsandwp_update_themeson thetwicedailyinterval. Without cron, the update counts in the admin go stale and stay stale: the site looks up to date while it isn’t. - Automatic background updates. Core fires the
wp_maybe_auto_updateaction from inside the scheduledwp_version_checkrun, so when that event stops firing, security releases stop installing themselves. - Scheduled backups. Almost every backup plugin schedules its runs through WP-Cron. The plugin’s own screen will happily show “next backup: tonight” forever.
- Housekeeping.
wp_scheduled_deleteempties the trash afterEMPTY_TRASH_DAYS(30 by default),delete_expired_transientsclears stale rows, andwp_scheduled_auto_draft_deleteremoves abandoned auto-drafts. All three are daily events. Rows accumulate. - Anything queued rather than sent immediately: newsletter batches, digest emails, abandoned-cart reminders, notification queues.
- WooCommerce and Action Scheduler. Action Scheduler keeps its own queue tables, but the queue runner is started by a WP-Cron event. When cron stalls, subscription renewals, order status transitions and scheduled emails sit at pending with due dates in the past.
- Sitemap regeneration, security scans, cache warmers, license checks, analytics roll-ups.
If you recognise more than one item on that list, stop diagnosing them individually. They share a cause.
Diagnosing a stalled scheduler
Start with Site Health. Tools → Site Health → Status runs two tests that matter here: one that inspects the cron array for events that are overdue or have no callback attached, and one that attempts a loopback request and reports whether it completed. “A scheduled event has failed” and “Your site could not complete a loopback request” are the two lines to look for. The second is close to a diagnosis on its own.
Then request the file yourself. From your own machine, and ideally over SSH from the server too:
curl -s -o /dev/null -w "%{http_code}n" https://example.com/wp-cron.php
A healthy response is 200 with an empty body. A 401 means HTTP auth is in front of it. A 403 usually means a firewall, a WAF rule or a security plugin. A redirect to a login page, a challenge page, or a 503 all mean the request is not reaching PHP. One note if you script this: wp-cron.php exits immediately when $_POST is not empty, so test with GET.
Look at what is actually scheduled. The standard tool is WP Crontrol, which adds a Tools → Cron Events screen listing every entry in the cron array with its next run time, its arguments, and a warning when a hook has no callback registered. It also runs a single event on demand, which is a fast way to separate “cron never fires” from “this one task is broken”. On the command line, WP-CLI does the same job:
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron test
If next_run_relative shows negative values across many unrelated hooks, cron is not running at all. If one overdue hook sits among healthy ones, you have a task that is fatally erroring partway through, and because it happens inside a discarded background request, that error never reaches a browser. It exists only in the PHP error log. The reading habits that apply to a white screen of death apply here too, with the difference that nobody is looking at the blank response.
Finally, check for a forgotten constant. This is the most common cause of a site where every scheduled task stopped on the same day:
grep -n "DISABLE_WP_CRON" wp-config.php
Disabling WP-Cron is half of a two-step fix. A host recommends it, a performance article recommends it, somebody adds the line, and the second half, the actual server cron job, never gets created, or gets created and then lost in a server migration. The site keeps working perfectly in every visible way while nothing scheduled has run for months. If you find that constant set, do not simply delete it. Read the next section and finish the job properly.
Diagnosing it is partly arithmetic, and the arithmetic is unkind to quiet sites.
Set your traffic below, along with how much of it is served from a page cache without ever starting PHP, and it works out how late a post scheduled for three in the morning will actually be. Then it writes the real cron line, in three variants, along with the wp-config constant that must only be added after the cron exists, not before.
WP-Cron reality check
WP-Cron is not a cron job: it only runs when somebody loads a page, so a quiet site publishes late, or not at all. Work out how late a scheduled post is likely to be, then copy the real cron job that ends the problem. Everything is worked out in this browser tab and nothing is sent anywhere.
The real cron job
Three ways to call WP-Cron on a fixed schedule. Pick one, not all three. The lines follow the address and the path you type here.
Fits when you can edit the crontab of the account that owns the site. Five fields, then the command: every fifth minute of every hour, every day. Use the second line if wget is not installed. Both throw the page away, they only want the request to happen.
Fits when WP-CLI is installed. It runs the due events in the shell instead of over HTTP, so no web server, no loopback request and no cache sits in the way, and a failure shows up in the cron mail instead of disappearing into a discarded response. Replace the path with the directory that holds wp-config.php.
Fits on shared hosting with no crontab. Point an uptime monitor or a cron service at this URL every five minutes. The URL has to be reachable from the public internet: behind a password, an IP allow list, a maintenance mode or a staging block it fails quietly and you are back where you started.
define( 'DISABLE_WP_CRON', true );
This switches the visitor trigger off, so page loads stop carrying the queue. On its own, with nothing calling wp-cron.php from outside, it stops every scheduled job on the site: posts, updates, backups, the lot. Put it above the line that says that is all, stop editing.
Order matters: set the cron job up first and watch it run, then add the constant. The other way round leaves the site with no trigger at all, and everything scheduled stands still until the cron job exists.
Check it, then clear the backlog
Two lines to run once the cron job is in place.
Every row is a hook with its next run. On a healthy site the next run of the top row is a few minutes away. If the first rows say something like 12 hours ago, the queue is stuck and nothing has been triggering it.
The answer you want is that WP-Cron spawning is working as expected. Anything else is usually a loopback failure: the site cannot call itself, often because of basic auth, a firewall or a wrong host entry. A real cron job or the WP-CLI line steps around that entirely.
The usual find on a site whose cron has not run for weeks: hundreds of events all due at once. The first run works through the whole backlog in a single request, which can take minutes and can hit the PHP time limit, so the run dies halfway and starts again from the front. Clear the backlog by hand with the WP-CLI line, watch it finish, and only then hand the job to cron.
Watch for an event that schedules its own successor. When it fails partway through it can be queued again on every look, so the same hook appears dozens of times with due dates a few seconds apart. That is a loop, not a busy site, and clearing it once is not enough: the plugin behind it has to be fixed or switched off.
The proper fix: a real cron job
The correct configuration for any site where scheduling matters is to stop relying on visitor traffic and give WordPress a real timer. It is two steps, and both are mandatory.
Step one: turn off the loopback trigger. Add this to wp-config.php, above the /* That's all, stop editing! */ line. Placement matters, because anything defined below the require_once ABSPATH . 'wp-settings.php'; that follows it runs after WordPress has already booted, which is too late to have any effect.
define( 'DISABLE_WP_CRON', true );
This stops _wp_cron() from spawning loopbacks on page loads. It does not disable wp-cron.php itself: the file still runs due events perfectly well when something requests it. The docblock at the top of that file says so directly: defining the constant and calling the file are mutually exclusive, and the second does not rely on the first. That distinction is the whole basis of step two.
Step two: add a server cron job. In your hosting panel’s cron section, or in the crontab of the user that owns the site files, add one of these. Use your real domain, and run it every five or fifteen minutes.
*/5 * * * * wget -q -O - "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
Or with curl, which most panels prefer:
*/15 * * * * curl -s -o /dev/null "https://example.com/wp-cron.php?doing_wp_cron"
Or, if WP-CLI is available, the strongest of the three:
*/5 * * * * /usr/local/bin/wp --path=/var/www/example.com/public cron event run --due-now >/dev/null 2>&1
The WP-CLI form has a real advantage: it makes no HTTP request at all. It loads WordPress directly in PHP and runs the due events. If the reason your cron broke in the first place is a firewall that blocks self-requests, this route sidesteps the problem rather than negotiating with it. Confirm the path to the wp binary with which wp and use the absolute path, because cron runs with a minimal environment and a short PATH.
Now the part that deserves the emphasis. Setting DISABLE_WP_CRON without adding the server cron job does not make WordPress more efficient. It stops every scheduled task on the site. No posts publish, no backups run, no updates are checked, no queued mail goes out. The admin shows no error, Site Health may still look green, and the failure can go unnoticed for months. If you cannot create a cron job on your hosting plan, leave the constant out and accept traffic-driven scheduling. Half of this fix is worse than none of it.
On interval: every fifteen minutes is fine for a blog, every five is a good default, and a store running Action Scheduler is better served by every minute, since Action Scheduler expects a fast tick and works through its queue in batches. Checking often is cheap but not free: each call boots WordPress, and if wp_get_ready_cron_jobs() comes back empty the script exits before doing anything else.
There is one fallback worth knowing about. ALTERNATE_WP_CRON replaces the loopback with a redirect: the visitor is sent back to the same URL with a doing_wp_cron parameter appended, the response is flushed, and wp-cron.php is then included inside that same PHP process. It works on hosts where loopbacks are impossible, but it is still entirely traffic-dependent, it adds a redirect to some page views, and it interacts badly with caching layers. Treat it as a last resort, not an equivalent option.
Page caches, CDNs, and requests that never reach PHP
There is a version of this problem that looks impossible at first glance: a busy site, thousands of visits a day, and cron still barely runs.
The explanation is that WP-Cron is triggered by PHP execution, not by traffic. A full-page cache (Varnish, an nginx FastCGI cache, a host-level edge cache, or a CDN configured to cache HTML) answers most requests from stored bytes without ever starting PHP. If PHP never starts, init never fires, wp_cron() is never called, and no loopback is spawned. The busier and better-cached the site, the fewer chances cron gets.
Since most page caches bypass caching for logged-in users, the practical result is that cron effectively runs only when an administrator is signed in and clicking around. That is exactly the site where posts publish the instant you open the dashboard and never a minute before. If you are already tuning caching layers, it is worth understanding how a full-page cache changes what actually reaches PHP, because the same mechanism that makes the site fast is what starves the scheduler.
The fix is the same one as above, and the better the cache, the more necessary it is. Aggressive caching and a real server cron job belong together.
Symptom and cause
Posts show “Missed schedule” but publish the moment you open the admin. Cron is firing, but only when somebody loads a page. Your overnight traffic is too low, or a page cache is answering visitors without touching PHP. Add a server cron job.
Everything scheduled stopped on the same day. Look for DISABLE_WP_CRON in wp-config.php with no matching server cron job, often introduced or orphaned during a migration or host change. This is the single most common cause of a total, silent stop.
Site Health reports that the loopback request could not complete. Something between the server and itself is refusing the request: a firewall rule, a security plugin, HTTP basic auth on a staging copy, or a WAF. Request wp-cron.php with curl and read the status code. It will usually name the culprit.
WooCommerce scheduled actions sit at pending with dates in the past. Action Scheduler’s queue runner is started by a WP-Cron event, so a stalled scheduler stalls renewals, emails and status transitions along with it. Fix cron first, then let the backlog drain.
Most events run, but one hook is permanently overdue. Cron is fine; that specific callback is fataling. Because the response to wp-cron.php is thrown away, the error only exists in the PHP error log. Run the event manually with WP Crontrol or wp cron event run <hook> and watch the log.
The same email or task fires two or three times. Usually a long-running event that outlives the doing_cron lock, letting a second run take the lock before the first has finished. WP_CRON_LOCK_TIMEOUT defaults to 60 seconds; a task that regularly takes longer than that needs its own guard, or needs moving out of WP-Cron.
A real timer, or none at all
WP-Cron is a reasonable design decision made for a world of shared hosting where nobody could add a crontab entry. It gives plugin developers a scheduling API that works everywhere, at the cost of being approximate. On a site with steady traffic and no full-page cache, the approximation is close enough that most people never think about it.
The moment scheduling matters (a publishing calendar, nightly backups, a store processing renewals), the approximation is not good enough, and the fix is small. Turn off the loopback with DISABLE_WP_CRON, add a server cron job every five or fifteen minutes, and confirm it worked by watching a scheduled post go out at the time it was set for. Both halves, or neither.
And when something scheduled misbehaves in future, resist debugging it in isolation. Check the cron array first. A post that missed its schedule and a backup that hasn’t run since March are not two problems; they are one problem with two faces.