DEV Community

Cover image for Why WordPress Missed Your Scheduled Post (and How to Fix WP-Cron)
Pro Gineous
Pro Gineous

Posted on Originally published at blog.progineous.com

Why WordPress Missed Your Scheduled Post (and How to Fix WP-Cron)

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.

Flat diagram comparing two ways WordPress publishes a scheduled post: the default visit-triggered WP-Cron path (visitor arrives, WordPress checks the job list, loopback request to wp-cron.php, job runs) with its failure points, and a server cron job running wp cron event run --due-now every five minutes

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
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of WP-CLI showing a scheduled post with ID 1482 still in future status, wp cron event list with publish_future_post and core jobs overdue as now, and wp cron test failing with cURL error 28 operation timed out

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: 401 means a password sits in front of the site.
  • An error saying the DISABLE_WP_CRON constant 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

Terminal screenshot of fixing WP-Cron with WP-CLI: wp config set DISABLE_WP_CRON true, a crontab line running wp cron event run --due-now every five minutes, the due events executing including publish_future_post, and post 1482 now showing publish status

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 list again. Recurring jobs like wp_version_check should show a future time, not now.
  • 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

Originally published on the Pro Gineous blog.

Top comments (0)