Why a WordPress scheduled post won’t publish, even when cron is fine

Hand-drawn whiteboard diagram: scheduling a post creates one publish_future_post event; if that single event is lost from the cron option the post stays stuck in future and returns 404 to visitors; core has no fallback sweep for overdue posts.

A field note of mine was scheduled for 9am. Four days later it still wasn’t public. The admin dashboard showed it sitting there, labelled “Scheduled,” looking exactly as healthy as a scheduled post should. That was the trap. The dashboard shows scheduled posts to me, because I am logged in and allowed to see them. To everyone who wasn’t me, the post’s URL had been returning a 404 for four days, and it was absent from the feed. The post looked published from the one seat in the house that can see drafts.

Every guide for this failure says the same thing, and it is worth reading them, because they are usually right. Your wp-cron is not firing. Low-traffic sites do not trigger it, so set up a real server cron, or install a plugin, or both. Reasonable advice. It was wrong here.

Cron was healthy. This site disables the default traffic-triggered cron and runs a real server job that hits wp-cron.php on a fixed interval, and the access log shows that job firing on time on the morning the post was supposed to publish, at the same minute past the hour it fired on every morning before it. Cron ran, on schedule, and the post did not publish. So the usual suspect had an alibi.

The proof came from staging. A copy of the exact same post, carrying the exact same scheduled event, had been cloned to a staging server days earlier. On the scheduled morning, staging published its copy on time, from staging’s own cron. Same post, same event, different server, correct result. The schedule itself was sound. Production alone had lost something between the two boxes.

Here is what it lost, read from WordPress 7.1. A scheduled post is published by exactly one disposable event. When you schedule the post, core registers a single publish_future_post event timed to the post’s date. When that event fires, check_and_publish_future_post() flips the status to publish. That function is wired to that hook in default-filters.php, and this one line is the whole system:

add_action( 'publish_future_post', 'check_and_publish_future_post', 10, 1 );

Grep wp-includes for anything that looks for overdue posts and sweeps them into publication and you find nothing. There is no safety net.

That one event lives inside a single serialized cron option, alongside every other scheduled task on the site. If any process writes a stale copy of that option back over the current one, it silently drops whatever was added since its snapshot. When the thing dropped is a publish_future_post event, the post waits in future forever, because the only thing that was ever going to publish it is now gone and nothing is scheduled to notice.

The fix is a fallback that publishes overdue future posts on every cron run:

add_action( 'wp_loaded', function () {
    if ( ! wp_doing_cron() ) {
        return; // tie the sweep to the physical server cron, not a stored event
    }
    $overdue = get_posts( array(
        'post_type'      => 'any',
        'post_status'    => 'future',
        'posts_per_page' => 20,
        'fields'         => 'ids',
        'date_query'     => array( array( 'before' => current_time( 'mysql' ) ) ),
    ) );
    foreach ( $overdue as $id ) {
        wp_publish_post( $id );
    }
} );

It is about fifteen lines, and its shape matters more than its length. The two popular plugins for this do it two ways. One registers its own recurring cron event, which lives in the same serialized option that just lost an event, so it can vanish the same way. The other runs on visitor page loads, which is thin cover on a quiet site, and a quiet site is exactly where a scheduled post is most likely to slip. Hooking wp_loaded and gating on wp_doing_cron() instead ties the sweep to the physical server cron job directly. It depends on no stored event surviving. A 9am post publishes at the next tick whether or not its one-shot event is still there. The copy I run is in a small repo of must-use plugins at https://github.com/markrusselldev/wp-mu-plugins if you want it as a drop-in.

There is a tell in the fact that this is one of the most documented failures in WordPress and still has no dominant plugin. The common cause got absorbed by hosting, which now ships real server cron by default, and the visibility got absorbed by core, which added a Site Health check that warns when scheduled events fail. What is left is a fifteen-line utility with no business model, so nobody builds a big one.

The lesson underneath all of it is older than WordPress. When every guide tells you to restart the thing that is obviously broken, confirm it is actually broken before you do. Mine wasn’t. The event was.