Guide

WordPress scheduled posts not publishing — diagnosing missed cron jobs

You hit "Schedule" on a post for 9:00 AM. The editor confirms it. The post sits there with the status "Scheduled" — and 9:00 AM comes and goes. At 9:15 you refresh the post list and the status now reads "Missed schedule".

1. Problem

You scheduled a post for 09:00. It is now past noon, and the post editor still shows Missed schedule in red next to the publish box. The post is not live. Nothing in the admin explains why, and clicking Publish to force it through just makes the next scheduled post do the exact same thing tomorrow.

If you searched "wordpress scheduled post missed schedule fix," the common advice is to reinstall WP Crontrol, flush permalinks, deactivate plugins one by one, or just get in the habit of manually publishing anything scheduled. That advice treats the symptom, not the mechanism, and does not explain why WordPress, a system that clearly knows the post was due at 09:00, did nothing for three hours. The real question is whether anything is actually running WordPress's scheduler at all, and on this site, right now, it usually is not.

A stuck scheduled post is one visible symptom of a broader condition that surfaces as the wp_cron_overdue_count signal: the number of scheduled jobs sitting past their due time, of which your 09:00 post is just the one you happened to notice.

2. Impact

A single late post looks like a minor annoyance you can fix by hand. The mechanism behind it is not minor: it is every time-based feature on the site failing quietly at once, and the manual fix does nothing to stop the next one.

  • Time-sensitive content loses its window. An embargoed announcement, a sale that was supposed to start at midnight, a press release timed to a competitor's launch: if it is late by three hours, being "eventually published" is not the same as being published on time. The value of the schedule was the timing, and that is exactly what broke.
  • Manually publishing does not fix the underlying problem. Clicking Publish resolves that one post and leaves the scheduler exactly as broken as it was. The next scheduled post, the next recurring digest, the next cleanup job: all of them queue up behind the same non-functioning trigger and go stale the same way.
  • It is never just posts. The same mechanism that publishes scheduled content also runs plugin maintenance, email queues, and data-retention cleanup. A site with a genuinely stalled scheduler can be sitting on hours of deferred emails and overdue housekeeping tasks that nobody has a reason to check, because nothing failed loudly.
  • It compounds with editorial trust. Once one scheduled post has silently sat unpublished for hours, editors stop trusting the schedule feature and start babysitting every post manually. That defeats the point of scheduling and adds a recurring manual task nobody signed up for.

3. Why It’s Hard to Spot

WordPress does not have a real scheduler. What it calls "cron" is a pseudo-cron: on every page load, WordPress checks whether any scheduled event is due, and if one is, it dispatches it inline as part of that request. There is no background daemon, no persistent process, nothing running while nobody is looking at the site. If no request arrives, nothing checks the schedule, and nothing runs.

This is invisible by design. WordPress was built to work without a real cron daemon on cheap shared hosting, so the pseudo-cron trick was a reasonable default in 2003. It has not aged well for uneven traffic: a blog with steady daytime visits but nothing overnight will have its 03:00 jobs sit untouched until the first visitor of the morning loads a page.

Nothing in the default admin surfaces this. Tools → Site Health does not check whether scheduled events are overdue. The post list shows Missed schedule only after the fact, on the one post you happen to be looking at, with no indication of how many other hooks are in the same state or how long they have been stuck. A cron-inspection plugin like WP Crontrol will list individual events if you go looking for them, but it will not tell you that overdue events have been trending upward for a week.

The failure is also easy to misdiagnose as something else. A "missed schedule" reads like a WordPress bug, so people chase permalink issues, timezone settings, or plugin conflicts before they ever check whether the pseudo-cron is receiving any requests to run on at all.

4. Cause

Publishing a scheduled post is itself a cron job. WordPress registers a publish_future_post event for the post's scheduled time, and that event only runs when the pseudo-cron dispatcher fires, which only happens on an incoming request. No request at 09:00 means no dispatch at 09:00, which means the post sits queued. The wp_cron_overdue_count signal counts exactly this: the number of scheduled hooks, across all plugins and core, whose due time has already passed.

Two distinct conditions produce this, and they need different fixes:

  • Low traffic starves the pseudo-cron. Nobody visited the site between 08:50 and 12:03, so nothing checked whether the 09:00 event was due until the first visitor at 12:03 triggered it. This shows up as a bursty, self-correcting wp_cron_overdue_count and a per-hook wp_cron_overdue_hook_delay_sec that spikes overnight or over weekends and drains once traffic resumes.
  • DISABLE_WP_CRON is set with no replacement. Someone (often a host, during a migration or a performance pass) added define('DISABLE_WP_CRON', true); to stop cron from piggybacking on every page load, intending to replace it with a real system cron hitting wp-cron.php on a schedule. If that replacement was never configured, or broke silently later, the pseudo-cron trigger is off and nothing else has taken its place. Every scheduled hook queues forever. If the config change happened recently enough to still be in range, it appears as wp_state_changes_total right before wp_cron_overdue_count starts its permanent climb rather than its usual bursty pattern.

A related but distinct signal is worth naming so you do not conflate it with this one: wp.cron type=missed_schedule fires when a hook eventually runs, just later than its own recurrence allowed. That requires the hook to run at all. On a site with DISABLE_WP_CRON set and nothing replacing it, the hook never runs, so it never reaches the point of producing a missed_schedule event. wp_cron_overdue_count and wp_cron_overdue_hook_delay_sec are the only signals that catch a hook stuck rather than merely late.

5. Solution

5.1 Diagnose (logs first)

Do not start by reinstalling plugins. Start by asking WordPress directly whether its scheduler is even receiving triggers, and how far behind it is.

1. List the scheduled events and their due times. This shows every hook WordPress knows about, including publish_future_post, and how overdue each one is.

wp cron event list --fields=hook,next_run_relative,next_run_gmt

# Filter to just the ones already due
wp cron event list --fields=hook,next_run_relative --due-now

A stuck 09:00 post shows up here as a publish_future_post row whose next_run_relative reads something like this:

publish_future_post 3 hours 4 minutes ago 2026-07-14 09:00:00 GMT

That gap between "now" and next_run_gmt is exactly what the wp_cron_overdue_hook_delay_sec signal reports per hook, and a nonzero count of rows like this is what increments wp_cron_overdue_count.

2. Check whether the pseudo-cron trigger is even enabled. If DISABLE_WP_CRON is set to true and nothing external calls wp-cron.php on a schedule, no page load will ever dispatch these events, no matter how much traffic the site gets.

wp config get DISABLE_WP_CRON

# Look for a real system cron or hosting scheduler already hitting wp-cron.php
crontab -l | grep -i wp-cron
grep -c "wp-cron.php" /var/log/nginx/access.log

If DISABLE_WP_CRON returns 1 or true and neither the crontab nor the access log shows anything hitting wp-cron.php, you have found it: the pseudo-cron trigger was turned off and nothing replaced it. Every hook, not just this one post, has been queuing indefinitely.

3. If DISABLE_WP_CRON is not set, check traffic around the missed time. On a low-traffic site the pseudo-cron is simply starved between visits, and the fix is different from a fully disabled scheduler.

# Any requests at all in the 3 hours the post sat overdue?
awk '$4 >= "[14/Jul/2026:09:00:00" && $4 <= "[14/Jul/2026:12:00:00"' /var/log/nginx/access.log | wc -l

A near-zero count confirms traffic starvation: the site had no visitors to trigger the pseudo-cron in the window the post needed it. In Logystera terms, wp_cron_overdue_hook_delay_sec for publish_future_post tracks this delay directly, and wp_cron_overdue_count tells you whether it was an isolated hook or the whole scheduler backing up.

5.2 Root Causes

Match what you found in diagnosis to the cause before changing anything.

  • DISABLE_WP_CRON returns true, no crontab entry, no wp-cron.php hits in the access log means the pseudo-cron was disabled and never replaced. Cause: an incomplete migration or performance change. Signal: a permanent, non-draining wp_cron_overdue_count, sometimes preceded by wp_state_changes_total if the config change is recent.
  • DISABLE_WP_CRON is false or unset, and the access log shows near-zero traffic during the missed window, means the pseudo-cron simply had nothing to run on. Cause: low or uneven traffic. Signal: wp_cron_overdue_hook_delay_sec spiking overnight or on weekends, then draining once traffic resumes.
  • The post eventually shows as published but hours late, with a corresponding wp.cron type=missed_schedule entry, means the hook did run, just later than it should have. This is a different failure from a hook that never fires at all, and it is covered separately in the cron reliability guide linked below.
  • A crontab entry exists and looks correct but wp_cron_overdue_count keeps climbing anyway usually means the cron entry is hitting the wrong path, the wrong PHP binary, or is silently failing. Check the cron job's own output or mail, not just its presence in the crontab.

5.3 Fix

The fix depends on which cause diagnosis found, but almost every low-traffic or misconfigured site benefits from the same underlying change: stop relying on visitor traffic to run the scheduler at all.

Disable the pseudo-cron trigger and replace it with a real one. This is the correct fix whether the problem is traffic starvation or a half-finished DISABLE_WP_CRON migration. Add the constant if it is not already there, and pair it with an actual system cron entry, because the constant alone does nothing without a replacement trigger.

// In wp-config.php, above the "stop editing" line
define( 'DISABLE_WP_CRON', true );
# System crontab, run every minute so scheduled posts publish on time
* * * * * wp --path=/var/www/html cron event run --due-now >/dev/null 2>&1

# Or, if WP-CLI is unavailable, hit wp-cron.php directly every 1-5 minutes
*/2 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

Running wp cron event run --due-now every minute is preferable when you have shell access: it runs due events directly instead of round-tripping through HTTP. Every 1 to 5 minutes is frequent enough that a 09:00 post publishes within a minute or two of its due time instead of waiting for the next visitor.

If a migration left DISABLE_WP_CRON set with nothing behind it, this is the whole fix. Add the crontab or hosting-scheduler entry above, and the queue of overdue hooks starts draining on the next tick, no code changes required.

If the site is genuinely low-traffic and you are not ready to touch server config, a WP-Cron pinger service (an external uptime-style checker that requests wp-cron.php on a schedule) is an acceptable stopgap, but it is strictly worse than a real system cron: it depends on a third party staying reliable, and it still round-trips through a full HTTP request for every tick. Prefer the system cron entry above once you have shell access.

For the one post stuck right now, you can force it through immediately while the scheduler-level fix takes effect, either from the editor or via WP-CLI:

wp post update 4821 --post_status=publish

Treat this as a one-off unblock, not the fix. If you stop here, the next scheduled post repeats the exact same failure.

5.4 Verify

The fix is confirmed when the overdue queue drains and stays drained, not when this one post goes live.

# Should return nothing, or only events due in the last minute or two
wp cron event list --due-now --fields=hook,next_run_relative

# Confirm the real cron is actually ticking (should show recent entries)
grep "wp-cron.php" /var/log/nginx/access.log | tail -n 5

Schedule a test post a few minutes out and let the new system cron handle it without touching the admin. If it publishes on time without manual intervention, the trigger is working. In the metric, wp_cron_overdue_count should fall from whatever backlog it had and settle near 0, and wp_cron_overdue_hook_delay_sec for publish_future_post specifically should stay in the single-digit-minutes range rather than climbing for hours. Watch it across a full day, including the overnight low-traffic window, since that is exactly when a starved pseudo-cron used to fail and a working system cron should not.

6. How to Catch This Early

A stuck scheduled post is the visible tip of a scheduler that has been failing quietly for however long nobody happened to look at the post list.

This issue surfaces as wp_cron_overdue_count.

Nothing in default WordPress alerts on an overdue hook. There is no dashboard notice for "your scheduler has not run in six hours," no email when the queue starts backing up, and the post editor only tells you about the one post you are viewing. A digest, a cleanup job, or a scheduled post can sit overdue indefinitely, and the only way anyone finds out is by noticing the downstream symptom, an editor checking a post, a customer asking where their email went, days after the scheduler actually stopped.

A count of overdue hooks over time answers what the admin UI cannot: is the scheduler draining its queue at a normal rate, or is the backlog growing without bound. A healthy site shows wp_cron_overdue_count spike briefly and drain, night after night. A site with a disabled trigger shows the same metric climb and never come back down. Reading that difference from the signal, rather than waiting for someone to notice a missed post, turns this from something you discover into something you are told about.

7. Related Silent Failures

  • wp_cron_overdue_hook_delay_sec climbing: the per-hook delay behind the aggregate count, useful for confirming whether one hook is stuck or the whole scheduler is backed up. Covered in the WP-Cron not running guide.
  • DISABLE_WP_CRON set with no replacement: the specific misconfiguration that turns a starved scheduler into a permanently dead one. See the WP-Cron disabled guide.
  • wp.cron type=missed_schedule: a hook that does run, just later than its own interval allowed, which is a related but distinct failure from a hook that never runs at all. See the stuck cron hook guide.
  • wp_state_changes_total near a cron config edit: a config change (like a migration adding DISABLE_WP_CRON) is the usual trigger for a scheduler that goes from occasionally slow to permanently stalled.
  • Averages hiding a bad tail: a mean delay of a few seconds can still mean one hook, like your 09:00 post, sat overdue for hours while everything else ran fine. See why max matters more than average for cron reliability.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Copyright © 2026 Logystera. Operated by 1969730 Ontario Inc., an Ontario, Canada corporation. All rights reserved.