A newsroom schedules its lead story for six in the morning. At 6:05 it is not on the homepage. Nobody did anything wrong: the post was written, scheduled, saved, and its publish time has come and gone. There is no error, no failed job, nothing in the log. The post just sits in the admin list with a small grey label beside it, Missed schedule, as though it had quietly decided not to happen.
This is the worst kind of bug, the completely silent kind. And on WordPress spread across more than one server, it is barely a bug at all. It is what the default setup does under load, and the fix nearly everyone reaches for makes it worse, in a way that is genuinely hard to see.
wp-cron is not cron
The name lies. wp-cron is not a system scheduler. On a default install, WordPress checks for due jobs on a normal page load: near the end of a request it fires a non-blocking loopback call to wp-cron.php, which runs whatever is due. No visitors, nothing runs. A quiet site can sit for hours with nothing firing; a busy one runs its cron on the back of a random reader's page view.
On one server that is merely untidy. Put it behind a load balancer and it starts to fail in ways you cannot see. The loopback goes to your own domain, which resolves to the load balancer, which hands it to some instance, not necessarily the one that fired it. If an instance cannot reach the public hostname, the loopback silently does nothing. You now have a scheduler whose reliability depends on reader traffic and internal networking, for jobs like publishing the morning's lead story.
The fix everyone gives you, and the one it hides
Search this and every answer is the same two lines: switch the loopback off, and drive cron yourself.
define( 'DISABLE_WP_CRON', true );
* * * * * wget -q -O - https://www.example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
We ran exactly that. Then, because a crontab lives on one machine and our machines come and go, we moved the trigger to a Lambda that hit the site every minute, so nothing depended on a single box staying alive. It looked correct. The request went out every minute, on schedule, forever.
And posts kept coming up Missed schedule.

Both rows want the same thing: the due events run on time. The top row triggers cron with an HTTP request, so it inherits everything sitting in front of the origin, a cache that can answer the same fixed URL without ever running PHP, a load balancer that can hand it to a draining node, and a web request that dies on a stuck lock or a mid-run recycle. The bottom row runs cron in-process with WP-CLI, so none of that is in the path.
The mistake is buried in that one-liner, and it is the same mistake whether a crontab or a Lambda fires it: we were still triggering cron with an HTTP request. Everything wrong with that survives the move to Lambda, because it has nothing to do with who makes the request and everything to do with the request itself.
Follow the path it takes. It hits your public URL, so it meets your CDN first. Our trigger was one fixed URL, wp-cron.php?doing_wp_cron, the same string every minute, which is precisely what a cache exists to answer without troubling the origin. So a good share of those tidy every-minute hits were served from cache as an empty 200, and PHP never ran. The dashboards were green. Cron was not running.
Past the cache it meets the load balancer, which sends it to whichever instance it likes, including one that is draining. If it does reach a live node, wp-cron.php runs as an ordinary web request, under the web server's timeout, holding a lock other requests respect for sixty seconds. Let one heavy job overrun that lock, or let the instance recycle mid-run, and cron wedges or dies with the queue half done. The publish_future_post event for the 6am story is somewhere in that unfinished tail.
None of it surfaces as an error. The trigger fires, something returns 200, and the post sits at Missed schedule until an editor asks why.
Stop hitting a URL
Here is the turn that fixed it, and it is smaller than it sounds: the reliable way to run cron makes no HTTP request at all.
wp cron event run --due-now
WP-CLI boots WordPress in a normal PHP process on the machine and runs the due events straight against the database. No page, so no CDN to cache it. No loopback, so no load balancer to misroute it. It runs in the CLI, which has no web timeout, so a slow job finishes instead of being cut off. Every fragile link in the HTTP path is simply not in this one.
That is the whole distance between "keep hitting the server so cron stays alive" and "run cron". Hitting a page keeps it triggered. It does not keep it finishing, on time, and finishing is the only thing a scheduled post cares about.
Which leaves one question, the one that turns this from a one-liner into a fleet problem: if cron now runs in-process on a machine, which machine?
Which machine?
Cron now runs in-process, which means it runs on whichever machine the crontab sits on. In a fleet, that innocent question is the whole problem, because there are only two obvious answers and both are wrong.
Pin it to one machine and you are back where you started. That machine is cattle: a deploy replaces it, a scale-in retires it, a health check recycles it, and your one crontab leaves with it. Cron stops, silently, and the 6am story misses again. A single runner is a single point of failure wearing a tidier hat.
So put it on every machine, bake it into the image, and let every node run cron. Now the opposite breaks. wp cron event run on each node executes the due events locally, against the shared database, in the same minute. The event that publishes the lead story fires on all of them. Readers get the newsletter three times, the post publishes and re-publishes, the feed importer runs in triplicate. The transient lock core uses for the loopback does not save you here, because each CLI process is its own world and, unless your object cache is shared, they cannot even see each other's lock.
Run everywhere, execute once
The answer is to keep cron on every node, so there is no single box to lose, and put one shared lock in front of the run, so only one node does the work each minute.

Every instance runs the trigger, so there is no single box to lose, but each run first tries to take a shared lock (a MySQL advisory lock, or a Redis key), and only the winner executes the due events. The runner records each successful pass, so a total stall is loud instead of silent.
Every node's crontab calls the same command every minute. The first thing it does is ask for a lock. Exactly one node gets it; the rest are turned away in a millisecond and exit having done nothing. The winner runs the due events and releases the lock. Next minute the race is run again, and it does not matter who wins.
For the lock you already have the one shared thing you need: the database. Every instance talks to the same MySQL, so a MySQL advisory lock is a coordinator with nothing new to deploy:
SELECT GET_LOCK('wp_fleet_cron', 0);
Ask for it with a zero-second timeout, run cron only if you got it, release it when you are done. There is a second reason to prefer this over the transient lock WordPress uses: an advisory lock lives on the database connection, so if the runner is killed mid-job, the connection closes and the lock releases itself. The stuck-lock failure that wedges the transient one for sixty seconds cannot happen here. If you run Redis across the fleet, a SET key value NX PX key does the same job; the point is that the lock is shared, not which store holds it.
Make the silence loud
One thing is still missing, and it is the thing that started all this. Elected and locked, cron runs correctly, right up until the day it does not: a bad deploy, a lock a broken database will not grant, a crontab that quietly stopped being installed. A scheduler that fails silently is the whole reason we are here.
So the runner writes down every successful pass, the time and the node that did it. That one record turns the unanswerable question, "is cron running?", into a check anything can make:
wp fleet-cron status || send-me-an-alert
It exits non-zero the moment the last run is older than it should be, so an outage pages you before an editor finds it. The missed 6am story stops being a mystery someone stumbles on and becomes an alert you already handled. It is the same rule that runs through our WordPress mail plugin: a failure you cannot see is worse than one that shouts.
The plugin
It is one file. Drop it into wp-content/mu-plugins/, add one line to wp-config.php, and run one command from system cron on every node:
define( 'DISABLE_WP_CRON', true );
* * * * * cd /var/www/html && wp fleet-cron run --quiet
Then hand the health check to your monitoring, so a stall reaches you rather than an editor:
wp fleet-cron status || send-me-an-alert
Or install it with Composer:
composer require mantekio/wp-fleet-cron
One honest word on where it is in its life. It is new, and it ships as 0.9.0. It has been exercised end to end against a real database and WP-CLI: a single runner elected out of five firing at once, a lock that frees itself when the holder is killed mid-job, the watchdog going loud the moment a run goes stale. What it has not yet had is months in a live multi-node fleet, so it is out in the open under the GPL, on GitHub and Packagist, being hardened in daylight rather than behind a curtain. The repository is github.com/mantekio/wp-fleet-cron. Same loop as the pieces before it: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.
Known limits
Being clear about what a tool does not do is part of the tool.
-
The lock assumes a single primary database. That is the normal case, and it is the one thing every node in a fleet already shares. On a multi-primary topology, or behind a connection pooler that hands one session to different callers, swap in a Redis key (
SET key value NX PX); the plugin leaves a seam for it. - It assumes you can run system cron on the nodes. If your host forbids that, keep the loopback, but the watchdog still tells you the instant cron stops.
- A long job serialises cron behind it. The lock is held for the length of a run, so a job that overruns the minute makes the next tick skip rather than stack a second copy on top. That is deliberate, you never want two, but a genuinely heavy job wants its own queue, not a seat in the cron tick.
- Some managed hosts already run cron correctly for a fleet. Check what yours does before adding anything.
Publishing a post on time is the most solved problem in WordPress, on one server. Across a fleet, on the morning a scheduled lead has to be live at six, it turns out to be a small distributed-systems problem in a CMS costume, and the fixes are the dull, correct ones: run cron where nothing can cache or misroute it, let exactly one node act, and make the silence loud the instant it falls. None of it is clever. All of it is the distance between a scheduler you hope is running and one you know is.
That is the shape of running WordPress on AWS for a newsroom: the real problems hide inside the boring infrastructure, and the work is making the boring parts unbreakable. If a scheduled post has ever quietly failed to appear, and you would rather it never did again, let's talk.
Top comments (0)