You scheduled a post for 11:00, closed the laptop, and came back after lunch to find it still sitting in the Posts list with a red "Missed schedule" label where the publish date should be. Nothing crashed. No error email arrived. WordPress just never got around to it.
The cause is almost always WP-Cron, the scheduler built into WordPress. It makes one big assumption about your traffic, and when that assumption fails, scheduled posts, backups and digest emails all slip together. Here's how it works, how to confirm it's the culprit, and how to replace it with something that runs on time.
WP-Cron isn't a clock
When you click Schedule, WordPress doesn't set a timer anywhere. It saves the post with the status future and adds a one-off job called publish_future_post to a list stored in the database (the cron option in wp_options). Plugin chores like backups and update checks live in the same list.
Nothing watches that list on its own. Instead, on each page request WordPress checks whether anything on it is due. If something is, it fires a quick HTTP request at its own wp-cron.php (a loopback request) and carries on without waiting for the reply. That second request is what actually publishes your post.
So the default setup needs three things to line up: a visit that reaches PHP, a loopback request that gets through, and a server that lets the job finish. Take away any one of them and the schedule drifts.
Why the schedule slips
Nobody visited. A small site can go hours without one uncached request. The post is due at 11:00, the first real visit comes at 12:40, and that's when it goes live. Posts on a quiet site appearing late by a random amount is the classic pattern.
The visits never reached WordPress. Full-page caching (a caching plugin's static files, a server-level cache, or a CDN in front of the site) is great for speed, but a cached page is served without loading WordPress, so nothing checks the job list. A busy site behind a good cache can behave exactly like a site with no visitors.
The loopback request fails. The server has to be able to reach its own domain over HTTP. A staging password (HTTP basic auth) answers 401. A security plugin or firewall rule may block wp-cron.php. After a migration, the server can still resolve your domain to the old IP (DNS answers are cached, as covered in how long DNS changes take to show up) and send the request to a machine that no longer hosts the site. WordPress flags this in Tools > Site Health as "Your site could not complete a loopback request."
Cron was switched off and never replaced. Someone added define( 'DISABLE_WP_CRON', true ); to wp-config.php to stop cron running on page loads, which is a sensible thing to do, but the server cron job that's meant to take over was never created. Or it existed on the old server and didn't come along in the move, because it lives in the server's crontab, not in WordPress.
A heavy job ran out of time. Cron jobs run in a normal PHP request, so they're bound by the same time limit. A big backup or import that hits that limit gets killed mid-run, and whatever was queued after it waits for the next spawn.
Also rule out the time zone under Settings > General. If it's set to UTC while you think in local time, every post goes out "late" by the same few hours. That's a settings problem, not a scheduler one.
Ask WordPress what it thinks is due
If your hosting gives you SSH, WP-CLI answers this in three commands. Run them from the site's folder (often public_html):
wp post list --post_status=future --fields=ID,post_title,post_date
wp cron event list
wp cron test
The first command lists posts still waiting to publish, with the time they were due in the site's own time zone. In wp cron event list, the next_run_gmt column is in UTC, so on a site set to Cairo time (UTC+3) the 08:00:00 job is the 11:00 post. Any row whose next_run_relative says now is overdue: WordPress knows the job should run and nothing has run it. One overdue row could be bad luck. A column full of now, including core jobs like wp_version_check, means cron hasn't run for hours.
wp cron test makes the same loopback request WordPress would, but waits up to three seconds for the answer. Here's how to read the result:
-
Success: WP-Cron spawning is working as expected.The loopback path is fine, so look at traffic and caching instead. -
cURL error 28(a timeout, as in the screenshot) usually points at a firewall rule, a DNS answer pointing somewhere else, or a server that can't reach itself. -
HTTP status code: 401means a password sits in front of the site. - An error saying the
DISABLE_WP_CRONconstant is set means cron is switched off on purpose, so go and find the server cron job that should exist.
No SSH? The free WP Crontrol plugin shows the same event list inside wp-admin, and Site Health reports late scheduled events and failed loopback requests.
Hand the schedule to the server
The reliable fix is to stop depending on visits at all. Let the server's own scheduler run the queue every few minutes, and tell WordPress to stop trying on page loads.
Start by switching off the visit-triggered runner:
wp config set DISABLE_WP_CRON true --raw
That writes define( 'DISABLE_WP_CRON', true ); into wp-config.php. You can also add the line by hand, anywhere above the /* That's all, stop editing! */ comment.
Next, add a server cron job. Cron runs with a very short PATH, so find the full path to WP-CLI with which wp and use it in the line. With WP-CLI available, this is the cleanest version, because it runs due events directly in PHP instead of going through HTTP:
*/5 * * * * cd /home/exampleco/public_html && /usr/local/bin/wp cron event run --due-now --quiet
Without WP-CLI, request wp-cron.php over HTTP instead:
*/5 * * * * curl -s -o /dev/null "https://example.com/wp-cron.php?doing_wp_cron"
The HTTP version still depends on the server reaching itself, so if wp cron test failed earlier, fix that first or stick with WP-CLI. Over SSH, add the line with crontab -e. On cPanel hosting, the Cron Jobs page takes the same command and has a ready-made "Once Per Five Minutes" option. Five minutes is a good default: scheduled posts go out within a few minutes of their time, and the server barely notices.
Finally, clear the backlog once by hand with wp cron event run --due-now, then check the stuck post with wp post get 1482 --field=post_status. It should now say publish.
If you really can't add a cron job
Some plans offer neither cron nor SSH. One fallback is an external cron service that requests https://example.com/wp-cron.php?doing_wp_cron every few minutes, which works as long as that URL isn't blocked or cached. The other is define( 'ALTERNATE_WP_CRON', true );, which makes WordPress run cron by redirecting a visitor's own request instead of making a loopback call. It sidesteps a broken loopback but still needs visits, and visitors may see ?doing_wp_cron= in the address bar. Treat both as stopgaps.
Prove it the next day
The day after the change, check that it actually works:
- Schedule a test post ten minutes out and stay off the site. If it goes live on time with no visits from you, the server cron is doing the work. (If new posts are shared to social media automatically, pause that first, then trash the test post.)
- Run
wp cron event listagain. Recurring jobs likewp_version_checkshould show a future time, notnow. - Add the cron line to your migration notes. The next time the site moves hosts, it's the one setting that won't move with the files and database, and if both old and new servers keep running cron against the same site during a cutover, scheduled emails can go out twice.
WP-Cron is a reasonable default for a site that gets steady, uncached traffic. For anything that publishes on a schedule or runs nightly backups, a five-minute server cron job is the boring, dependable choice. Pro Gineous shared hosting plans include cPanel, SSH and WP-CLI, so everything above can be set up from the control panel or the terminal in a few minutes.
Related reading
- Speeding Up WordPress Without More Plugins
- How to Move a WordPress Site to a New Host
- How to Fix a WordPress Error Without Making It Worse
Originally published on the Pro Gineous blog.



Top comments (0)