Skip to content
  • FG-65
  • 13 min read
  • Last checked November 8, 2026

How to Fix “Missed Schedule” Posts and WP-Cron Not Running in WordPress

Scheduled posts show “Missed schedule” and backups or emails never run. Find out why WP-Cron is not firing, replace it with a real system cron and clear stuck events.

Run the health check

You scheduled a post for 9:00 and at 9:30 it is still not live. In Posts > All Posts it sits with the red label “Missed schedule”. Often other things are quietly broken too: scheduled backups have not run for days, WooCommerce follow-up emails are late, a newsletter digest never went out, or a plugin warns that its scheduled actions are past due.

These all depend on WP-Cron, WordPress’s built-in task scheduler. WP-Cron is not a real clock. It only runs when something triggers it, and on many sites that trigger is quietly failing. This guide is for site owners, editors, designers and developers who need scheduled posts to go out on time, every time.

What is happening

WordPress stores every scheduled task (an “event”) in a single database option named cron: a list of timestamps, each with the hook name to fire and its arguments. A scheduled post is just an event on the hook publish_future_post, set for the post’s publish time.

Nothing watches that list on its own. Instead, on ordinary page loads WordPress checks whether any event is due. If one is, it sets a lock (a transient named doing_cron) and sends a quick request from the server to its own wp-cron.php file, without waiting for the answer. That separate request, called a loopback, runs the due events in the background. Since WordPress 6.9, this check happens at the very end of the page load so it does not delay the page.

So four things have to go right for an event to run:

Requirement What breaks it
A page load reaches PHP around the due time Low traffic, or a full-page cache or CDN serving every visitor without running WordPress
WP-Cron is allowed to spawn DISABLE_WP_CRON set to true with no real cron job replacing it
The loopback request reaches wp-cron.php Basic auth, a firewall or WAF, Cloudflare challenges, DNS pointing elsewhere, an HTTP to HTTPS redirect
wp-cron.php runs to the end A slow or broken event that times out or crashes, taking the rest of the queue with it

The doing_cron lock stops two runs from overlapping. It expires after WP_CRON_LOCK_TIMEOUT seconds, which is 60 by default.

Before you start

You need SSH access for the most reliable checks, and access to your hosting panel’s cron jobs screen (often called “Cron Jobs” or “Scheduled Tasks”). None of the read-only checks change anything. Before you delete any cron events in Step 6, export the database with wp db export.

How to run the commands in this guide. Server commands run in an SSH terminal from your site folder, the one that contains wp-config.php (often public_html). WP-CLI is called wp on most servers; some hosts name it wp-cli, so swap the name if wp is not found. The curl tests in Step 4 run on the server, because the point is to see what the server sees when it calls itself.

Step 1: Confirm cron is not running

Test the spawn mechanism

wp cron test

Run this over SSH from the site folder. There are no placeholders. It checks whether DISABLE_WP_CRON is set (an error if it is), whether ALTERNATE_WP_CRON is set (a warning), and then tries to spawn WP-Cron over HTTP. Good output is “Success: WP-Cron spawning is working as expected.” Bad output is an error saying WP-Cron is disabled, or a warning that the spawn request returned a non-200 status code, with that code. Note the code; Step 4 explains what each one means.

See what is overdue

wp cron event list --fields=hook,next_run_gmt,next_run_relative,recurrence
wp post list --post_status=future --fields=ID,post_title,post_date,post_date_gmt

Run these over SSH from the site folder. The first lists every scheduled event. --fields chooses the columns: next_run_gmt is the due time in UTC, next_run_relative is how far away it is, and recurrence is how often it repeats (“Non-repeating” for one-off events such as a scheduled post). The second lists posts still waiting to publish.

Good output shows next_run_relative values in the future, with only a few saying now. Bad output is a long list of events showing now, especially publish_future_post, wp_version_check and your backup plugin’s hooks. That means events are due but nothing is running them. Posts in the second list whose post_date_gmt is in the past are the ones showing “Missed schedule”.

Publish the overdue posts now

wp cron event run --due-now

Run this over SSH from the site folder. --due-now runs every event whose time has passed, and respects the doing_cron lock so it will not overlap with a run already in progress. Good output lists lines like “Executed the cron event ‘publish_future_post’ in 0.05s.” and ends with a total. If it finishes cleanly and the posts are now live, WordPress itself works and only the trigger is broken. If it stops with a PHP fatal error, note the hook it was running; that event is crashing the queue (see Step 6).

Step 2: Check whether WP-Cron was switched off

wp config get DISABLE_WP_CRON
grep -n "WP_CRON" wp-config.php

Run these over SSH from the site folder. The first prints the value of the constant if it is defined, or an error saying it is not defined. The second shows every cron-related line in wp-config.php, with line numbers (-n).

If DISABLE_WP_CRON is true, page loads never trigger cron, by design. That is a good setup only when a real cron job runs it instead. This is a very common cause of missed schedules right after a migration: the constant traveled with wp-config.php, but the server cron job lived in the old host’s panel and did not. Either set up the system cron in Step 3 (recommended), or remove the line to return to traffic-triggered cron.

Step 3: Replace WP-Cron with a real system cron

A system cron runs on the server’s real clock whether or not anyone visits. It is the most dependable fix and also removes cron work from visitor page loads.

Disable the traffic trigger

define( 'DISABLE_WP_CRON', true );

This is a line you add to wp-config.php, above “That’s all, stop editing!”. It stops page loads from spawning cron. Do this only together with the cron job below, or events will stop entirely.

Add the cron job, preferably with WP-CLI

command -v wp
crontab -e

Run these over SSH. The first prints the full path to WP-CLI (for example /usr/local/bin/wp); cron jobs run with a minimal PATH, so you need the full path. The second opens your user’s crontab in an editor. Add one line and save:

*/5 * * * * cd SITE_PATH && WP_PATH cron event run --due-now --quiet >/dev/null 2>&1
  • */5 * * * * means every five minutes. The five fields are minute, hour, day of month, month and day of week.
  • SITE_PATH is the full path to the folder with wp-config.php; print it with pwd while you are in that folder.
  • WP_PATH is the path from command -v wp. If your site needs a specific PHP version, prefix it with that PHP binary, for example PHP_BINARY WP_PATH cron event run ..., where PHP_BINARY is the path your host documents for that version.
  • --quiet suppresses success messages, and >/dev/null 2>&1 discards all output so cron does not email you every five minutes.

If you use your hosting panel’s cron screen instead of crontab -e, choose “every five minutes” and paste everything after the five timing fields as the command.

How to confirm it works: temporarily change the end of the line to >> ~/wp-cron.log 2>&1 and remove --quiet, wait ten minutes, then read ~/wp-cron.log. Good: “Executed the cron event” lines or “Success: Executed a total of” lines every five minutes. Bad: “command not found” (wrong WP_PATH), “This does not seem to be a WordPress installation” (wrong SITE_PATH), or PHP errors. Switch back to /dev/null once it works.

Multisite

*/5 * * * * cd SITE_PATH && for URL in $(WP_PATH site list --field=url); do WP_PATH cron event run --due-now --quiet --url="$URL"; done >/dev/null 2>&1

This is a crontab line, with the same placeholders. On multisite, every site has its own cron option, so the loop lists every site URL with wp site list --field=url and runs due events for each with --url. WP-CLI also has a --network flag for this, but at the time of writing it is only in the nightly build. Large networks may want to stagger sites across several cron lines.

When you cannot run WP-CLI from cron

*/5 * * * * curl -sS -o /dev/null "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1

This crontab line calls wp-cron.php over HTTP on a schedule. Replace example.com with your domain. -sS hides progress but still shows errors, and -o /dev/null discards the page. It works without WP-CLI, but it still goes through the web stack, so it is subject to PHP timeouts and the same loopback problems as Step 4. Some panels also offer external cron services that call this URL for you.

Step 4: Fix a blocked loopback

If you prefer to keep traffic-triggered cron, or wp cron test reported a non-200 code, find out what the server gets when it calls itself:

curl -sS -o /dev/null -w "%{http_code} %{time_total}s %{remote_ip}\n" -X POST "https://example.com/wp-cron.php?doing_wp_cron"
getent hosts example.com

Run these over SSH on the web server. Replace example.com with the domain in your WordPress Address setting. The -w option prints the status code, total time and the IP address curl connected to. getent hosts shows which IP the server resolves your domain to, including any /etc/hosts entry.

Result Likely cause Fix
200, fast, your server’s IP Loopback works; the problem is traffic or caching Use the system cron from Step 3
401 Basic auth (password protection) on the site, common on staging Exclude wp-cron.php from basic auth, or use the system cron
403 or 503 with a Cloudflare IP A Cloudflare WAF rule, Bot Fight Mode or a challenge blocking the server’s own request Skip the challenge for your server’s IP, or use the system cron
403 from your server A security plugin, ModSecurity rule or .htaccess block on wp-cron.php Find the rule in the plugin or WAF log and allow the server’s IP
301 or 302 The WordPress Address uses http:// or a different hostname, and the server redirects; the non-blocking spawn does not follow redirects Make the WordPress Address and Site Address match the final URL exactly
Timeout, or a different server’s IP A firewall, a NAT that cannot reach its own public IP, or DNS still pointing at an old server See our loopback request guide for the full diagnosis

Our separate guide to the Site Health loopback error covers each of these causes in depth, including testing with curl --resolve and hosts file fixes.

Step 5: ALTERNATE_WP_CRON as a last resort

define( 'ALTERNATE_WP_CRON', true );

This is a line you add to wp-config.php. Instead of a background loopback, WordPress redirects the visitor’s own browser to the same URL with ?doing_wp_cron=TIMESTAMP added, and runs cron inside that visit. It sidesteps a blocked loopback, but it has real costs: the visitor briefly sees the extra query string, it only triggers on GET requests from real visitors, it can create odd URLs in analytics and caches, and it still needs traffic. wp cron test warns when it is set. Use it only on hosts where you have neither a working loopback nor a cron job option.

Step 6: Clear stuck events and a bloated cron option

Find events that run too often or belong to nobody

wp cron event list --format=count
wp cron event list --field=hook | sort | uniq -c | sort -rn | head -20
wp cron event list --fields=hook,next_run_relative,recurrence,actions

Run these over SSH from the site folder. The first counts all events. The second prints only the hook name of each event (--field=hook) and counts duplicates, most frequent first. The third adds the actions column, which lists the functions attached to each hook.

A healthy site has a few dozen events, with each hook appearing once or a handful of times. Warning signs are one hook appearing hundreds or thousands of times (a plugin scheduling a new event on every page load without checking for an existing one) and events with an empty actions column (orphans from a plugin you removed: they run, do nothing, and clutter the list).

Check the size of the cron option

wp db query "SELECT LENGTH(option_value) AS bytes FROM TABLE_PREFIXoptions WHERE option_name = 'cron';"

Run this over SSH from the site folder. TABLE_PREFIX is the value of $table_prefix in wp-config.php (often wp_, making the table wp_options). The result is the size of the serialized cron list in bytes. A few kilobytes to a few tens of kilobytes is normal. Hundreds of kilobytes or more means a runaway plugin: every page load reads this option, and every cron run rewrites the whole thing, so a huge value slows the site and can make writes fail or clash.

Delete the bad events

wp cron event delete HOOK_NAME

Run this over SSH from the site folder after taking a database export. HOOK_NAME is the hook from the list above. It deletes every scheduled event for that hook. Good output is “Success: Deleted a total of N cron events.” where N is the count. Do not delete core hooks such as wp_version_check, wp_update_plugins, wp_scheduled_delete or publish_future_post. If a plugin keeps recreating thousands of events, update or replace that plugin, or the list will refill.

A stuck lock

wp transient get doing_cron
wp transient delete doing_cron

Run these over SSH from the site folder. The first shows the lock value, a Unix timestamp with decimals, if a lock exists. Normally it clears itself within 60 seconds. If it persists far longer, often because the server clock was wrong or an object cache kept a stale value, the second line removes it. Good output: “Success: Transient deleted.”

Advanced: Find the event that kills the queue

wp-cron.php runs due events one after another in a single PHP request. If one event takes longer than max_execution_time, or hits a fatal error, every event after it in that run is skipped, and the next run hits the same event again. The symptom is a queue that never drains even though cron does fire.

Run due events from WP-CLI, which is not bound by the web server’s timeouts, and watch the timings:

wp cron event run --due-now 2>&1 | tee ~/cron-run.txt
grep "^Executed" ~/cron-run.txt | sort -t' ' -k7 -g | tail

Run these over SSH from the site folder. 2>&1 folds errors into the output, and tee shows it while saving it to ~/cron-run.txt. The second line keeps only the “Executed the cron event ‘HOOK’ in …” lines and sorts them by the duration in the seventh space-separated field (-t' ' -k7), using general numeric order (-g), so the slowest events are printed last. Good output is a list where every duration is a fraction of a second or a few seconds; the event that ran last before an error, or that takes tens of seconds, is your suspect. To test one hook alone, run wp cron event run HOOK_NAME. Heavy jobs such as backups are better scheduled through the system cron or the plugin’s own server-side runner than squeezed into a web request.

Two related problems live elsewhere: plugins that use Action Scheduler (WooCommerce and many others) keep their own queue, covered in our slow wp-admin guide, and a wrong timezone in Settings > General makes posts publish at the wrong hour rather than miss. Check that the timezone is a city, not a bare UTC offset, if your schedule is consistently early or late by whole hours around daylight saving changes.

When to contact your host

Open a ticket if you cannot add a cron job in your panel, if wp cron test shows a non-200 code you cannot trace, or if the server cannot reach its own domain. Send:

  • the domain and the output of wp cron test;
  • the curl result from Step 4, with the status code, IP and UTC time;
  • whether DISABLE_WP_CRON is set and the exact cron job line you added;
  • any PHP errors from the log around the time cron should have run.

Our diagnostic tools help you check how your domain resolves and responds, and if the whole site is misbehaving, start at the Emergency Room.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close