Troubleshooting

WordPress Missed Schedule: Why WP-Cron Is Not a Cron Job

WP-Cron has no timer and no background process. It is a list of due tasks that gets checked only when somebody loads a page, which explains missed post schedules, stalled backups, and every other scheduled task that silently stops.

WordPress Missed Schedule: Why WP-Cron Is Not a Cron Job

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.

Comparison of a real cron job firing at 03:00 exactly against WP-Cron, which waits for a visitor and runs the same task five hours late

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_plugins and wp_update_themes on the twicedaily interval. 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_update action from inside the scheduled wp_version_check run, 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_delete empties the trash after EMPTY_TRASH_DAYS (30 by default), delete_expired_transients clears stale rows, and wp_scheduled_auto_draft_delete removes 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.

Table of everything that silently stops with WP-Cron, including scheduled posts, backups, update checks, transients, email queues and shop actions

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.

30
When the visits arrive
Special cases
Working it out
Expected delay
-
Worst case, 95 in 100
-
Missed schedule risk
-
PHP requests a day
-
Within 5 min-
Within 15 min-
Within 60 min-
Working it out

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.

crontab, every five minutes

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.

WP-CLI, the clean way

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.

An external service

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.

wp-config.php, once the cron job is proven
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.

What is queued

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.

Whether the trigger works at all

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.

WunderPaint
The Dynamic Design and Automation Studio
WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

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.

The two-step WP-Cron fix showing DISABLE_WP_CRON in wp-config.php plus a server cron job, and why setting only the constant stops every scheduled task

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.

WordPress Missed Schedule: Why WP-Cron Is Not a Cron Job

Table of Contents

Learn it by building something

Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

One mail a week, and then it ends.
Unsubscribe in one click.

Troubleshooting

The WordPress White Screen of Death Explained

A white screen with no error message isn't a WordPress bug. It's PHP dying silently mid-request. Here's how to see the real error in minutes and the exact order to check plugins, theme, and memory limits.

Troubleshooting

Why Your WordPress Images Look Blurry, and Which Cause It Actually Is

Blurry WordPress images are not one problem but six, and each leaves a different fingerprint. Work out which symptom you have before you change anything, because most of the fixes do nothing for most of the causes.

SEO & Structured Data

Redirect Rule Generator: The Rules That Never Fire

A redirect list rarely breaks. It accumulates: a broad prefix rule that kills every rule below it, a target missing a trailing slash that doubles your hop count, a 301 where a 308 was needed. Generate the Apache, nginx and CSV versions of your rules, then walk a URL through them and watch where it actually goes.

Photo Editing

WordPress Watermark: Which File Actually Gets Stamped

A watermark can go on the original, on the sub-sizes WordPress generates, or on top at display time. Only two of those put pixels in the file, and only one of them is reversible.

Photo Editing

Curves, Explained Without the Maths

One adjustment does the work of brightness, contrast, black point, white point and most color correction. It looks technical and it is the simplest tool in the room.

Troubleshooting

WordPress Images Washed Out: The Colour Profile Fix

The photograph looked right in your editor and flat on the site. Here is where WordPress and its plugins drop the colour profile, and the export habit that ends it.

Download the free WunderPaint Plugin for WordPress

The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

The Image Editor & Design Studio

Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

Free

Chaos Art

Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

Pro

Particle Strokes

Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

Pro

City Diorama

Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

Free

Papercut Art

Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.