Guide
WordPress scheduled posts not publishing — diagnosing missed cron jobs
1. Problem
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". The post never published. Or worse: the status still says "Scheduled" hours later, but nothing has actually gone out.
This is the canonical "wordpress scheduled post not publishing" problem, and if you search for it you'll see the same thread on every forum: "wordpress missed schedule error", "wordpress post stuck in draft scheduled", "scheduled post showing missed schedule". Everything looked fine yesterday. The dashboard says nothing useful. The post is stuck and your editorial calendar quietly breaks.
If you publish content on a timer — newsletters, embargoed press releases, drip campaigns, time-sensitive promotions — this isn't a cosmetic glitch. It's a silent failure in the heart of WordPress's scheduling system, and unless you go looking for it in the logs, you will not know it happened until a human notices the post never went live.
2. Impact
Missed schedules are not a UI bug. They are publishing failures. The consequences scale with your operation:
- Publishing SLAs break. Press releases miss embargoes. Sponsored posts go out hours late, after the campaign window has closed. Affiliate offers expire before they go live.
- Email and social automations stall. Plugins like Mailchimp, MailPoet, and Jetpack Publicize hook into the post-publish event. If the post never publishes, the email never sends and the tweet never posts.
- Cache and CDN warmups misfire. Pages built around a 9:00 AM go-live are still 404 at 9:05 because nothing was published.
- WooCommerce sales and coupons break. Scheduled product changes, scheduled coupon activations, and scheduled price drops all ride on the same
wp-cronmachinery. - Editorial trust erodes. Editors stop trusting the scheduler and start manually publishing — which defeats the point of having one.
The failure is also recurring. Once wp-cron is unhealthy, it stays unhealthy until something forces it back into a working state. You don't get one missed post — you get a slow, intermittent drip of them.
3. Why It’s Hard to Spot
WordPress is bad at telling you cron is broken. The admin dashboard does not have a "cron health" panel. Site Health (Tools → Site Health) will sometimes flag "A scheduled event has failed", but it's rate-limited, easy to miss, and only flags events that have already failed — not the underlying mechanism.
It feels random because it is triggered by traffic. A site with steady traffic will run cron frequently and probably hit your 9:00 AM post within a minute or two. A site with bursty traffic will go quiet from 8:00 AM to 9:30 AM and miss the window entirely. Two identical posts on two identical sites can behave completely differently.
Uptime monitors don't catch it. Pingdom or UptimeRobot hitting your homepage every 5 minutes is technically helping trigger cron, not detecting that it's broken. A 200 OK from / tells you nothing about whether publish_future_post actually ran.
The error message itself — "Missed schedule" — appears only in the post list inside wp-admin. It does not appear in error_log. It does not generate an admin email. It does not show up in any plugin's notification system unless you've installed something specific. Editors discover it by accident, often hours after the fact, often after a customer complains.
That's the silent failure. The scheduler is broken, the post didn't publish, and nothing in the WordPress UI raises its hand.
4. Cause
WordPress does not have a real cron daemon. It has wp-cron.php, a pseudo-cron triggered by site traffic. Every time someone visits a page on the front end, wp_cron() runs in the request lifecycle, looks at scheduled events, and executes any whose timestamps have passed. Scheduled posts are stored as a hook called publish_future_post with the post ID as an argument and a _doing_it_now lock to prevent duplicate runs.
When a scheduled post fails to publish, what's actually happening is one of three things:
- The
publish_future_posthook never fires becausewp-cron.phpisn't being triggered. - The hook fires but the lock from a previous run was never released, so the event is skipped.
- The hook fires but PHP execution dies before
wp_publish_post()completes — usually due to a memory limit or fatal error in a plugin hooked intotransition_post_status.
In Logystera, this surfaces as a wp.cron signal with type=missed_schedule. The signal is emitted by the WordPress plugin when it detects a post whose post_date_gmt is more than ~10 minutes in the past, whose status is still future (or has been transitioned to missed), and whose _publish_lock indicates a stalled or never-fired hook. The signal payload contains the post ID, the scheduled time, the actual detection time, and the delta. Alongside it, the plugin emits wp.cron type=health_check on a heartbeat that confirms whether wp-cron.php is being invoked at all — the absence of those heartbeats (the cron.run signal not arriving on its expected cadence) is itself a diagnostic signal.
wp.cron type=missed_schedule is the post-mortem evidence. The absent cron.run heartbeat is the upstream cause. Reading them together tells you whether the cron system is broken, or just one event inside it.
5. Solution
5.1 Diagnose (logs first)
Start by confirming wp-cron.php is actually running. The fastest path is to check the WordPress debug log, the web server access log, and the scheduled events table. Each of these maps to a specific signal.
Step 1 — Confirm the missed event exists in the database. SSH to the host and run:
wp post list --post_status=future --fields=ID,post_title,post_date_gmt --format=table
wp cron event list --fields=hook,next_run_relative,next_run_gmt | grep -E "publish_future_post|missed"
If publish_future_post shows a next_run_relative of "1 hour ago" or longer, the event is stuck. That's the database state behind a wp.cron type=missed_schedule signal.
Step 2 — Confirm wp-cron.php is being hit. Tail your web server access log:
tail -f /var/log/nginx/access.log | grep wp-cron.php
# or for Apache:
tail -f /var/log/apache2/access.log | grep wp-cron.php
You should see wp-cron.php requests appearing roughly in line with your site traffic. If you see nothing for 10+ minutes during business hours, wp-cron isn't being triggered. That absence is what produces the missing cron.run heartbeat — and it's the upstream cause of the wp.cron type=missed_schedule signal you'll see downstream.
Step 3 — Check whether WP_CRON was disabled. Look in wp-config.php:
grep -n "DISABLE_WP_CRON\|ALTERNATE_WP_CRON" /var/www/html/wp-config.php
If define('DISABLE_WP_CRON', true); is set, WordPress will not run cron from page loads at all — a real system cron must be configured. This is a common, silent root cause.
Step 4 — Check PHP error log for fatals during cron runs. If cron is firing but events are dying mid-flight:
grep -E "Fatal error|Allowed memory size|Maximum execution time" /var/log/php-fpm/error.log | tail -50
grep "publish_future_post\|wp_publish_post" /var/log/php-fpm/error.log
A fatal during publish_future_post produces a php.fatal signal correlated with the missed publish — and explains why the lock was never released.
Step 5 — Check the DB lock. A stuck lock will block subsequent runs:
wp option get cron --format=json | python3 -m json.tool | head -40
wp eval 'var_dump(get_transient("doing_cron"));'
If doing_cron returns a stale Unix timestamp from minutes or hours ago, a previous cron run died without clearing it. Subsequent runs will see the lock and silently exit. Clear it:
wp transient delete doing_cron
Step 6 — Manually fire the event. Force the scheduled post to publish to confirm the hook itself works:
wp cron event run publish_future_post
If this succeeds, your handler is fine — the problem is upstream (cron isn't being triggered). If it fails with an error, that error is the same one killing scheduled cron runs.
Each of these checks maps to a Logystera signal: wp.cron type=missed_schedule for the database state, the cron.run heartbeat (or its absence) for whether cron is firing at all, and php.fatal correlated by request ID for in-flight crashes.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
The likely root causes, in order of frequency:
Cause A — Low-traffic site with default wp-cron. If your site doesn't get a steady stream of visitors, wp-cron simply doesn't run often enough. This is the most common cause, and it produces a missing cron.run heartbeat, which precedes a wp.cron type=missed_schedule signal.
Fix: disable WordPress's pseudo-cron and run a real system cron.
# In wp-config.php:
define('DISABLE_WP_CRON', true);
# Then add to crontab (every 5 minutes):
*/5 * * * * curl -fsS https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
# or, better, run via PHP CLI to bypass HTTP entirely:
*/5 * * * * cd /var/www/html && /usr/bin/php wp-cron.php > /dev/null 2>&1
Cause B — Stale doing_cron lock. A previous cron run crashed, fataled, or timed out without releasing the lock. Subsequent runs see the lock and bail out. Surfaces as wp.cron type=missed_schedule repeating on a cadence with no php.fatal from the current run, but often a php.fatal from minutes earlier.
Fix:
wp transient delete doing_cron
wp cron event run --due-now
Cause C — Plugin fatal during transition_post_status. A plugin (commonly an SEO, social-share, or email-marketing plugin) hooks publish_future_post or transition_post_status and fatals. PHP dies, the post never moves to publish, the lock isn't released. Surfaces as a php.fatal signal correlated with the missed event.
Fix: identify the offending plugin from the PHP error log stack trace, deactivate it, retry the publish:
grep -B2 -A20 "publish_future_post" /var/log/php-fpm/error.log
wp plugin deactivate offending-plugin
wp post update <ID> --post_date="now" --post_status=publish
Cause D — Memory exhaustion. Large posts with many post-publish hooks (image regeneration, sitemap rebuild, search index rebuild) blow the PHP memory limit. Surfaces as Allowed memory size of N bytes exhausted in PHP error log → php.fatal → missed schedule.
Fix: raise WP_MEMORY_LIMIT and php.ini memory_limit, or stagger heavy post-publish hooks.
# wp-config.php
define('WP_MEMORY_LIMIT', '512M');
# php.ini or php-fpm pool
memory_limit = 512M
Cause E — Server-level cron overlapping with itself. If your system cron runs wp-cron.php every minute on a site with long-running events, runs collide and lock each other out. Use flock to serialize:
*/5 * * * * /usr/bin/flock -n /tmp/wp-cron.lock /usr/bin/php /var/www/html/wp-cron.php > /dev/null 2>&1
Cause F — DNS or HTTP loopback failure. If wp-cron is invoked over HTTP and the server can't resolve its own hostname (common in containerized setups), every spawn fails silently. Switch to PHP CLI invocation as in Cause A.
5.4 Verify
Verification has two parts: the missed-schedule signal must stop, and the heartbeat must stabilize.
Signal that should stop appearing: wp.cron type=missed_schedule. After the fix, no new instances should be emitted for at least 60 minutes under normal posting activity. If you can't wait, force the test:
wp post create --post_status=future --post_date="$(date -u -d '+3 minutes' '+%Y-%m-%d %H:%M:%S')" \
--post_title="cron test $(date +%s)" --post_content="test"
sleep 360
wp post list --post_status=future --fields=ID,post_title,post_date_gmt
If the test post is gone from the future list and now shows in wp post list --post_status=publish, the publish path works. No wp.cron type=missed_schedule should be emitted for that post.
Signal that should appear regularly: the cron.run heartbeat. Healthy WordPress cron emits a heartbeat every few minutes (every 5 minutes if you've configured system cron, more variable on traffic-driven cron). The Logystera plugin emits wp.cron type=health_check on this same cadence. If those heartbeats reappear with no gaps longer than your configured cron interval, the underlying mechanism is healthy.
What to grep for:
# Should now show recent wp-cron.php hits
tail -200 /var/log/nginx/access.log | grep wp-cron.php
# Should show zero pending overdue events
wp cron event list --fields=hook,next_run_relative | grep -i "ago"
# Should be empty or show a fresh timestamp, not stale
wp eval 'var_dump(get_transient("doing_cron"));'
Healthy looks like: wp-cron.php requests appearing in access logs at your configured interval, no future-status posts whose post_date_gmt is in the past, no stale doing_cron transient, and no wp.cron type=missed_schedule signals for 60+ minutes.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as wp.cron (type=missed_schedule).
The hard part of this failure is not fixing it. The hard part is knowing it happened. WordPress will not email you. Site Health will not page you. Your uptime monitor will report a green homepage while your 9:00 AM newsletter quietly never goes out.
The only honest signal that scheduled posts are failing is in the logs and the database — the missed-schedule status, the absent wp-cron.php access log entries, the stale doing_cron transient, the php.fatal during a publish_future_post run. If you're not actively reading those, you discover the problem when an editor or a customer tells you.
This is exactly the failure mode Logystera is built for. The wp.cron type=missed_schedule signal fires the moment a scheduled post drifts past its expected publish time. The cron.run heartbeat — and the alert on its absence — surfaces the upstream cause: that wp-cron itself has stopped firing. The two signals together give you both the symptom (a specific post missed) and the mechanism (cron is broken or slow), without anyone having to refresh wp-admin.
This is what "log-first" means in practice: the failure is detectable in logs the instant it happens, days before anyone would notice through normal channels.
7. Related Silent Failures
Other failures in the same wp.cron and scheduling cluster, all with the same shape — silent, traffic-dependent, invisible in the dashboard until something downstream breaks:
- Scheduled email digests not sending — MailPoet, Newsletter, or Jetpack subscription emails ride on
wp-cron. Same root cause, different symptom: subscribers stop receiving the daily digest. Surfaces aswp.cron type=missed_scheduleon the email-dispatch hook plus an absentwp.email type=sentsignal. - WooCommerce scheduled actions backing up — Action Scheduler tables grow unbounded when cron is unhealthy. Surfaces as a growing
wp_actionscheduler_actionscount withstatus=pendingandwp.cron type=missed_scheduleonaction_scheduler_run_queue. - Auto-updates silently not running —
wp_version_check,wp_update_plugins, andwp_update_themesare all cron events. A broken cron means your security updates quietly don't apply. Surfaces as the absence ofwp.state_change type=plugin_updateover expected windows. - Transient cleanup not happening —
delete_expired_transientsruns on cron. Without it,wp_optionsbloats with thousands of expired transients, slowing every page load. Surfaces as a slow-query signal correlated with missed cron heartbeats. - REST API enumeration going undetected — Adjacent failure mode: the
auth.attemptcluster covers brute-force and credential-stuffing detection that depends on the same plugin pipeline being healthy. Ifwp-cronand the plugin's heartbeat are broken, your detection layer is also degraded.
The pattern across all of these: a quiet, traffic-dependent subsystem that nobody monitors until something downstream breaks. The first place these failures are visible is in the logs. That's the only place that doesn't lie.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.