"Missed schedule" means WordPress reached the time a post was supposed to publish and the post is still sitting at the status future. It is a label drawn in the Posts list, not a state WordPress stores, and nothing in core retries after it appears.
That matters because there are two completely different failures hiding behind the same red text. In one, the publish job is still queued and just needs something to run it.
In the other, the job is gone from the queue, and the post will never publish no matter how long you wait.
Every guide on the first page of results treats these as one problem and recommends a server cron job. A server cron only fixes the first one.
The label is cosmetic
The red text comes from WP_Posts_List_Table::column_date() in wp-admin/includes/class-wp-posts-list-table.php:
$time = get_post_timestamp( $post );
$time_diff = time() - $time;
if ( 'publish' === $post->post_status ) {
$status = __( 'Published' );
} elseif ( 'future' === $post->post_status ) {
if ( $time_diff > 0 ) {
$status = '<strong class="error-message">' . __( 'Missed schedule' ) . '</strong>';
That is the whole mechanism. A post with status future and a timestamp one second in the past is labelled "Missed schedule".
There is no grace window, no retry counter and no flag written to the database. Reload the page a minute later and, if the job ran in between, the label is simply gone.
So the label tells you nothing about the cause. It tells you the clock passed the date.
What is supposed to happen
Saving a post with a future date fires _future_post_hook() in wp-includes/post.php:
function _future_post_hook( $deprecated, $post ) {
wp_clear_scheduled_hook( 'publish_future_post', array( $post->ID ) );
wp_schedule_single_event( strtotime( get_gmt_from_date( $post->post_date ) . ' GMT' ), 'publish_future_post', array( $post->ID ) );
}
So a scheduled post is a single cron event named publish_future_post, carrying one argument: the post ID.
When that event runs, check_and_publish_future_post() checks the status is still future, compares strtotime( $post->post_date_gmt . ' GMT' ) against time(), and calls wp_publish_post().
If the time has not arrived yet it reschedules itself, under the comment "Uh oh, someone jumped the gun!".
Nothing else in core publishes a scheduled post. If that event does not run, the post stays future forever.
The one command that splits the two failures
Ask whether the event still exists:
wp cron event list --fields=hook,next_run_relative,args | grep publish_future_post
If you see a row for your post ID, the job is queued and overdue. WP-Cron is not being triggered, and that is a delivery problem.
If you see nothing, the event has been lost. The post is orphaned, and no amount of fixing cron will publish it.
Cross-check which posts are affected:
wp post list --post_status=future --fields=ID,post_date,post_title
Any ID in that list with a past date and no matching event is in the second category.
Failure one: the event is queued and nothing runs it
WP-Cron is triggered by page loads. At the end of a request, core sends a non-blocking loopback request to wp-cron.php with a 0.01 second timeout, and that request is what runs due events.
On a site with no visitors at 3am, nothing triggers it. The post publishes late, whenever the next person arrives.
That is the normal, boring version of this problem, and it is why the stock advice exists.
The less obvious version is a loopback that fails. spawn_cron() returns ! is_wp_error( $result ), and with blocking set to false and a 0.01 second timeout, the function reports success almost regardless of what the far end does.
A wp-cron.php returning 401 behind HTTP basic auth, or a siteurl pointing at a hostname the server cannot resolve after a migration, both look like a healthy spawn from the inside.
There is also a floor. spawn_cron() refuses to run if the doing_cron transient is newer than WP_CRON_LOCK_TIMEOUT, which defaults to 60 seconds, so events never fire more often than once a minute.
A stuck transient blocks spawning for up to ten minutes, which is when spawn_cron() treats the lock as stale and clears it.
For this class of failure, a real cron job is the right fix, and our write-up of what wp-cron.php actually does covers the constants involved.
Failure two: the event is gone
This is the one nobody writes about, and it is the one where the post never publishes.
The entire schedule lives in a single autoloaded option. _set_cron_array() calls update_option( 'cron', $cron, true ), writing the whole array back, and _get_cron_array() reads the whole array in.
Every scheduling call is a read-modify-write of one option.
Two requests that schedule or unschedule events at the same moment both read the same array, and the second write wins. Whatever the first one added is silently gone.
A plugin that bulk-reschedules events, or a long-running import, makes that race much more likely.
wp_schedule_single_event() also refuses duplicates:
/*
* Check for a duplicated event.
*
* Don't schedule an event if there's already an identical event
* within 10 minutes.
*/
Identical means the same hook and the same serialized arguments. Core sidesteps it by calling wp_clear_scheduled_hook() first, which is exactly what _future_post_hook() does.
Plugin code that reschedules publish_future_post without clearing first can have its call rejected and get false back, and most such code does not check the return value.
The fix for a lost event is to recreate it, or just publish the post:
wp post update 123 --post_status=publish
To put the event back instead of publishing immediately, re-save the post with its future date. _future_post_hook() runs again and schedules a fresh event.
Timezones do not break cron, they move the event
"Check your timezone" is in every guide without an explanation, and the explanation is the interesting part.
_future_post_hook() converts $post->post_date with get_gmt_from_date(), which uses the site's timezone setting. The event timestamp is therefore an absolute UTC time derived from your configured offset.
If the setting is wrong by five hours, the event is scheduled five hours away from when the author meant. Cron is working perfectly; it is firing at the time you actually asked for.
The post shows "Missed schedule" in between because the editor shows local time while the queue holds UTC.
Changing the site timezone afterwards does not move events that are already queued. They were computed at save time.
Reproduce it before you change production
A sandbox is a good place to work on this precisely because it has no visitors, so WP-Cron never fires by itself and you control every trigger.
Clone the live site with the Cloner, then open a shell. SSH and SFTP access ship with WP-CLI preinstalled, so the whole queue is inspectable:
wp cron event list
wp cron test
wp cron event run --due-now
wp cron test checks whether the loopback actually works, which is the test the admin screens do not give you. wp cron event run --due-now runs the queue directly in PHP, bypassing the loopback entirely.
That bypass is the useful bit. If --due-now publishes the post, the callback is fine and your problem is triggering.
If it fails there too, the problem is in the callback or in a plugin hooked to future_to_publish, and you now have a stack trace to read with debug mode on.
Our longer guide to testing a WordPress cron job walks through that loop in more detail.
What the popular fixes actually do
A server cron job calling wp-cron.php on a timer fixes delivery. Queued events run on schedule whether or not anyone visits.
It does nothing for a lost event.
The "missed schedule" plugins publish overdue future posts when a visitor arrives. They work on both failures, because they query posts rather than reading the cron queue, but they publish late rather than on time, and they hide the underlying fault.
Raising the memory limit is sometimes the real fix, but only when the cron run is fataling partway through. You will see that in the debug log, not in the Posts list, and a fatal mid-run also loses the remaining events for that batch.
The short version
Check whether the event exists before you change anything.
Event present and overdue means WP-Cron is not being triggered: set up a real cron job, and check the loopback. Event absent means the post is orphaned: republish it, and look for whatever cleared the queue.
The label itself is just arithmetic on a timestamp, and it is the same label for both.
