A post with a past timestamp and a status of future is not a post problem. The editor labels it Missed Schedule because WordPress expected the post to be published by a scheduled event, and that event has not run. The post record is fine. The spawn path is not.
This is a trace of that spawn path: how wp_cron() registers the runner, how spawn_cron() uses the doing_cron transient as a lock, what WP_CRON_LOCK_TIMEOUT gates, and why the distinction between a held lock and a stale lock determines your next move.
Symptom
You open a post that was scheduled for 09:00. It is now 09:40. The editor shows Missed Schedule. The wp_posts row still has post_status = 'future' and a post_date_gmt in the past. Nothing in the post table explains why. The explanation lives in the cron option and the transient that guards it.
Two things must both be true for the post to publish:
- The publish event must exist in the cron array with a timestamp that has passed.
- Something must actually invoke
wp-cron.phpso that event is drained.
Missed Schedule means one of those failed. The label does not tell you which.
Trace: how the spawn path is wired
WordPress does not run cron on a system timer by default. It runs it when a page request finishes. The registration happens in wp_cron(), which the documentation describes as registering _wp_cron() to run on the shutdown action. The same documentation notes that as of 6.9.0 the callback moved from wp_loaded to shutdown unless ALTERNATE_WP_CRON is enabled, and that the function now always returns void.
The source shown in the reference is explicit about the branch:
function wp_cron(): void {
if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
if ( did_action( 'wp_loaded' ) ) {
_wp_cron();
} else {
add_action( 'wp_loaded', '_wp_cron', 20 );
}
} elseif ( doing_action( 'shutdown' ) ) {
_wp_cron();
} else {
add_action( 'shutdown', '_wp_cron' );
}
}
So on a normal install, _wp_cron() runs at shutdown. Under ALTERNATE_WP_CRON, it runs at wp_loaded. That difference matters when you are reading a slow page and a missed schedule at the same time.
_wp_cron() is the function that decides whether to run due events inline or to spawn a loopback request. The spawning itself is spawn_cron(), documented as sending a request to run cron through an HTTP request that does not halt page loading.
Trace: the lock arithmetic in spawn_cron()
Before spawn_cron() fires anything, it reads the doing_cron transient and treats it as a float. The reference source shows the guard:
$lock = (float) get_transient( 'doing_cron' );
if ( $lock > $gmt_time + 10 * MINUTE_IN_SECONDS ) {
$lock = 0;
}
// Don't run if another process is currently running it or more than once every 60 sec.
if ( $lock + WP_CRON_LOCK_TIMEOUT > $gmt_time ) {
return false;
}
Three things are happening here.
First, the transient is read as a float. It is written as a microsecond-precision string, so the cast is not cosmetic.
Second, a lock more than ten minutes in the future is treated as zero. That is a sanity check against a clock skew or a corrupted value, not a timeout.
Third, the real gate: if $lock + WP_CRON_LOCK_TIMEOUT is still ahead of the current time, spawn_cron() returns false and does nothing. The comment in core describes this as preventing a second run while another process is running it, or more than once every sixty seconds.
The constant WP_CRON_LOCK_TIMEOUT is referenced here but its value is not stated in the retrieved reference. Do not assume a number. Read it from your install:
wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );'
If that returns nothing, the constant is not defined in your bootstrap and you are looking at a different problem than the one this article covers.
Trace: the second gate, the ready queue
Even with a clear lock, spawn_cron() will not spawn if there is nothing ready to run. The reference source shows two more returns:
$crons = wp_get_ready_cron_jobs();
if ( empty( $crons ) ) {
return false;
}
$keys = array_keys( $crons );
if ( isset( $keys[0] ) && $keys[0] > $gmt_time ) {
return false;
}
This is the gate that produces the most confusing version of the symptom. If every event in the cron array is dated in the future, wp_get_ready_cron_jobs() returns an empty set and spawn_cron() exits without a loopback. Nothing is broken. There is simply nothing due.
That is why a missed schedule investigation should always start by reading the cron option, not by clearing transients.
Trace: the loopback request itself
Once the lock is clear and the ready queue is non-empty, spawn_cron() writes the lock and builds the request. The reference source shows the write:
// Set the cron lock with the current unix timestamp, when the cron is being spawned.
$doing_wp_cron = sprintf( '%.22F', $gmt_time );
set_transient( 'doing_cron', $doing_wp_cron );
Then it builds the URL and the request arguments. The defaults are worth reading closely:
'args' => array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),
A 0.01 second timeout, non-blocking, SSL verification off for the local request. The cron_request filter lets you change all of it.
The wp_cron() documentation adds a caveat that matters for diagnosis: wp_remote_post() does not always respect the timeout and blocking parameters, and a timeout of 0.01 may end up taking one second. The same documentation explains that this is why the callback moved to shutdown: running it at wp_loaded increased Time To First Byte because the HTML could not be sent while waiting for the cron request to initiate.
So the loopback is a hint, not a guarantee. It may complete in milliseconds. It may take a second. It may be dropped by a host that blocks loopback requests. None of that is visible from the editor.
Root cause: held lock versus stale lock
From the post editor, a held lock and a stale lock look identical. Both produce Missed Schedule. They require opposite responses.
A held lock means another process is mid-run. The transient was written by a spawn_cron() call that has not yet been followed by a completed wp-cron.php run. The correct action is to wait, then re-check. If the lock clears and the event drains, nothing was wrong except timing.
A stale lock means the transient outlived its process. The PHP worker that wrote it died, or the loopback never reached wp-cron.php, or wp-cron.php started and was killed before it could clear the lock. The transient is now a tombstone. Until it ages out past WP_CRON_LOCK_TIMEOUT, spawn_cron() will keep returning false and no new loopback will fire.
You cannot tell which one you have by looking at the post. You have to read the transient and compare it to the current time.
Diagnostic sequence
Run these in order. Stop when you find the answer.
1. Read the lock
wp transient get doing_cron
If it returns a float, compare it to time(). If the value is in the past by more than WP_CRON_LOCK_TIMEOUT, the lock is not the blocker. If it is in the future, note by how much.
You can also read it directly:
wp db query "SELECT option_name, option_value, FROM_UNIXTIME(option_value) FROM wp_options WHERE option_name IN ('_transient_doing_cron','_transient_timeout_doing_cron');"
The timeout row tells you when the transient will expire on its own. That is the window during which spawn_cron() will keep bailing.
2. Confirm the event exists and is overdue
wp cron event list --fields=hook,next_run_relative,next_run_gmt,recurrence
Find the publish_future_post entry for your post. If it is not there, the problem is upstream of the lock: the event was never scheduled, or it was scheduled with mismatched arguments and exists under a different key.
The wp_schedule_event() documentation warns about exactly this. Arguments are used to uniquely identify the scheduled event and must match those used when the event was originally scheduled. If they do not match exactly, WordPress treats the event as different, which can lead to duplicate events and growth of the cron option.
Check for duplicates before you assume the lock is the cause:
wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt,args
3. Check the ready queue
wp eval 'print_r( wp_get_ready_cron_jobs() );'
If this is empty while wp cron event list shows overdue events, you have a mismatch between what is stored and what is considered ready. That is a different failure than the lock.
4. Test the loopback
curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' "https://example.com/wp-cron.php?doing_wp_cron"
Replace the host. A 200 with a short time means the endpoint is reachable. A 403, 401, or a timeout means the loopback is blocked and spawn_cron() will keep writing locks that never get cleared.
5. Check the constants
wp eval 'var_dump( defined("DISABLE_WP_CRON") ? DISABLE_WP_CRON : null, defined("ALTERNATE_WP_CRON") ? ALTERNATE_WP_CRON : null );'
If DISABLE_WP_CRON is true, the spawn path is intentionally off and something else must be invoking wp-cron.php. If ALTERNATE_WP_CRON is true, the callback runs at wp_loaded, not shutdown, and the TTFB symptoms differ.
6. Read the cron option directly
wp option get cron --format=json | head -c 2000
This is the raw array. If it is enormous, you have duplicate events and the ready-queue gate is harder to reason about. If it is empty, nothing is scheduled at all.
Fix
The fix depends on which of the two lock states you found.
If the lock is held and recent: wait. Re-check the transient after WP_CRON_LOCK_TIMEOUT has passed. If the event drains, the system worked. If it does not, the lock is stale and you have moved to the next case.
If the lock is stale: clear it and let the next page request spawn a fresh loopback.
wp transient delete doing_cron
Then trigger a request to the site and confirm the event drains:
curl -sS -o /dev/null "https://example.com/"
wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt
If the event is gone and the post is published, the stale lock was the cause. If the event is still there, the loopback is not reaching wp-cron.php and you need to fix that before clearing transients again.
If the ready queue is empty while events are overdue: the lock is not your problem. Inspect the cron option for argument mismatches and duplicates, and reconcile the scheduled events against the hooks your code actually registers.
If the loopback is blocked: either fix the host configuration so wp-cron.php is reachable, or define DISABLE_WP_CRON and drive wp-cron.php from a real system cron. The wp_schedule_event() documentation notes that the default behavior of waiting for a visitor may not suit resource-intensive hooks, and suggests disabling internal cron and fetching the URL from the host control panel or executing the file directly with PHP.
Verification
After the fix, confirm three things:
- The transient is gone or has been rewritten with a current timestamp.
- The overdue event is no longer in
wp cron event list. - The post status is
publish, notfuture.
wp transient get doing_cron
wp cron event list --hook=publish_future_post
wp post get 123 --field=post_status
If all three are clean, the spawn path completed. If the transient reappears with a fresh timestamp but the event does not drain, the loopback is firing and wp-cron.php is not completing. That is a different trace, and it starts with the server error log, not the database.
What this does not cover
The retrieved reference for spawn_cron() and wp_cron() does not include the body of wp-cron.php itself, nor the _get_cron_lock() helper. Claims about how wp-cron.php iterates the queue and clears the lock are outside the evidence used here. If your investigation reaches the point where the loopback fires and the lock is rewritten but events still do not run, you are past the scope of this trace and into the internals of the cron runner.
The pre_schedule_event filter is worth knowing about because it can short-circuit scheduling entirely. The hook reference states that returning a non-null value will short-circuit adding the event to the cron array, causing the function to return the filtered value instead. If a plugin on your install returns a value from that filter for publish_future_post, the event never enters the array and no lock state will explain the missed schedule.
FAQ
Does clearing the doing_cron transient always fix a missed schedule?
No. It only helps when the lock is stale. If the lock is held by a running process, clearing it can cause a second concurrent run. If the ready queue is empty, clearing the lock changes nothing.
Why does the editor say Missed Schedule instead of showing an error?
The label reflects that the scheduled publish event did not fire. It does not distinguish between a lock problem, a loopback problem, and a missing event. That distinction is what the diagnostic sequence above is for.
What is WP_CRON_LOCK_TIMEOUT set to?
The retrieved reference for spawn_cron() uses the constant but does not state its value. Read it from your install with wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );' rather than assuming a number.
Does the 6.9.0 shutdown change affect missed schedules?
It changes where the spawn attempt happens in the request lifecycle. Under the default path, _wp_cron() runs at shutdown, so the loopback fires after the HTML is flushed. Under ALTERNATE_WP_CRON, it runs at wp_loaded, which the documentation notes increases TTFB. If you are correlating slow pages with missed schedules, check which branch your install is on.
Can duplicate cron events cause missed schedules?
They can make the ready queue harder to reason about and inflate the cron option. The wp_schedule_event() documentation warns that mismatched arguments cause WordPress to treat events as different, leading to duplicates and database performance issues. Check for duplicates before concluding the lock is the cause.
Sources
- spawn_cron() reference — lock arithmetic, ready-queue gate, loopback defaults.
- wp_cron() reference — shutdown registration, 6.9.0 changelog, timeout caveat.
- wp_schedule_event() reference — argument matching, duplicate event warning, disabling internal cron.
- pre_schedule_event hook reference — short-circuit behavior.