WordPress Cron and Why It Fails Silently
Somewhere on your WordPress site right now, there's a queue of scheduled promises: publish this post Thursday at 9am, run the backup tonight, send the abandoned-cart email in an hour, check for updates twice a day. WordPress will keep those promises — probably. The scheduling system responsible for them, WP-Cron, is a clever workaround for a real constraint, and like most clever workarounds it has failure modes that are invisible until you go looking. Nothing crashes. No error appears. The backup just... didn't run, and you find out three weeks later, at the worst possible moment.
This is a guide to how WP-Cron actually works, the specific ways it fails without telling you, how to see inside it, and how to make it reliable.
WP-Cron is not cron
Real cron — the Unix scheduler — is a daemon: an always-running system process that wakes at the appointed minute and runs your task, whether or not anyone is visiting anything. WordPress, designed to run on the cheapest shared hosting where users have no access to system schedulers, couldn't assume cron exists. So it built a substitute with one crucial difference:
WP-Cron only runs when someone visits your site.
The mechanism: on (almost) every page load, WordPress checks its list of scheduled tasks. If anything is overdue, it fires a non-blocking loopback request to wp-cron.php on itself — essentially the site phoning its own back door — and that spawned request works through the due tasks while the visitor's page load continues unaffected. The task list itself lives in a single autoloaded row in the options table (cron), an array of timestamps, hook names, and recurrence intervals.
Read that mechanism again and the failure modes practically announce themselves. Scheduling depends on traffic. Execution depends on the site being able to make an HTTP request to itself. The whole ledger is one database row. Each of these is a thing that quietly goes wrong.
The five silent failure modes
1. No traffic, no cron
The obvious one, and still the one that bites the most sites. If nobody visits between 2am and 7am, nothing scheduled for 3am runs until 7am. For a low-traffic brochure site, a "nightly" backup might actually run mid-morning — or skip days. WP-Cron guarantees tasks run no earlier than scheduled, and makes no promise whatsoever about how late. The classic symptom is the "Missed Schedule" label on posts that were supposed to auto-publish: the publish moment passed during a traffic gap, and the check that would have published it came too late.
A wrinkle that makes this worse, not better: aggressive full-page caching. If your page cache serves most visitors without executing PHP, those visits don't trigger the cron check. A well-cached low-traffic site is the perfect storm — precisely the setup many small business sites run.
2. The loopback request fails
WP-Cron requires your server to successfully make an HTTP request to itself. A surprising number of environments break this: security plugins or firewall rules blocking the loopback, hosts that resolve the site's own domain incorrectly from inside, basic-auth on staging sites (the cron request doesn't know the password), SSL misconfigurations the internal client rejects. The result is a scheduler that ticks but never fires — tasks are checked, the spawn request fails, and WordPress does not consider that an error worth telling anyone about. Site Health (Tools → Site Health) does test loopback requests, and it's the first thing to check when cron misbehaves.
3. One bad task jams the queue politely
Tasks in a WP-Cron run execute sequentially in one PHP process, subject to the server's execution time limit. A single task that hangs — a backup plugin trying to zip 15GB, an API call to a dead endpoint with no timeout — can consume the whole window, and the tasks queued behind it simply don't run that cycle. Do this every cycle and the queue develops a permanent traffic jam. WP-Cron also uses a lock (a transient) to prevent overlapping runs; a task that dies mid-run can leave things in a state where the next run doesn't start when expected. From the outside: everything looks scheduled, nothing happens, no errors.
4. The schedule itself gets corrupted or bloated
Because the entire schedule is one serialized array in one options row, it has single-point-of-failure physics. Plugins that schedule events carelessly — a new event on every page load without checking whether one exists, a typo'd hook name so events are scheduled but no code ever handles them — bloat the array with thousands of dead entries. Since the row is autoloaded, that bloat rides along on every request. In the worst case the serialized data corrupts, and scheduling breaks entirely, silently.
5. Deactivated plugins leave ghosts
When you deactivate a plugin, its scheduled events usually remain — orphaned hooks with no callback attached. Harmless individually, but they accumulate over a site's life, and they make the schedule harder to read when you're debugging the real problem. A five-year-old site typically carries a small graveyard of these.
Seeing inside the black box
You can't fix what you can't see, and WP-Cron ships with no UI at all. Two ways in:
- WP Crontrol (plugin): shows every scheduled event, its hook, recurrence, and next run; lets you run any event immediately, delete orphans, and spot the plugin behind each hook by its name prefix. If you administer WordPress sites, this belongs in your standard toolkit.
- WP-CLI:
wp cron event listfor the schedule,wp cron event run <hook>to execute one on demand,wp cron testto verify the spawning mechanism works at all.
The debugging sequence that resolves most cron mysteries, in order:
- Does spawning work?
wp cron test, or Site Health's loopback check. If this fails, nothing else matters — fix connectivity or move to real cron (below). - Is the event scheduled? Find it in Crontrol. Missing event → the plugin's scheduling logic is the issue (often it only schedules on activation, and something cleared it).
- Does it run when forced? Run the event manually. If it works when forced but not on schedule, you're looking at failure modes 1–3: traffic gaps, jams, or locks.
- Is the past-due list enormous? A long backlog of overdue events means the runner isn't keeping up — look for the slow task jamming the queue.
The real fix: replace the trigger with actual cron
Here's the part every serious WordPress operation eventually does, and that you can do in ten minutes. WP-Cron's scheduling ledger is fine — it's the trigger (visitor traffic) that's unreliable. So keep the ledger, replace the trigger:
Step 1 — Stop page loads from triggering cron. In wp-config.php:
define('DISABLE_WP_CRON', true);
(Slightly misleading name: it disables only the traffic-based spawning, not the scheduling system.)
Step 2 — Have the system trigger it on a fixed heartbeat. Either a real crontab entry on the server:
*/5 * * * * wget -q -O - https://yoursite.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
or, better where available, the WP-CLI variant, which avoids HTTP entirely along with all its loopback failure modes:
*/5 * * * * cd /path/to/wordpress && wp cron event run --due-now >/dev/null 2>&1
Most decent hosts expose a cron UI for exactly this; managed WordPress hosts often run a system-level cron for you out of the box — worth confirming rather than assuming.
Why every five minutes and not every minute? Five minutes is granular enough for anything WordPress schedules (the tightest built-in recurrence people commonly use is hourly) while keeping overlap risk low if a run occasionally takes long. If you run WooCommerce with time-sensitive flows, every minute is defensible — WooCommerce's own Action Scheduler benefits from frequent ticks.
After this change: scheduled posts publish on the minute, backups run at 3am whether or not anyone's awake, and the traffic-dependence failure mode is gone permanently. This single configuration change is the highest-reliability-per-effort move in WordPress operations.
Rules for scheduling your own tasks well
If you (or your developer) write code that schedules events, five field rules prevent the messes described above:
- Always check before scheduling. Guard every schedule call with a check that the event doesn't already exist (
wp_next_scheduled()) — the classic bug is scheduling a duplicate on every page load. - Clean up on deactivation. Unschedule your hooks in the deactivation routine. Don't leave ghosts.
- Keep tasks short; chunk long work. A cron task should do minutes of work at most. Processing 10,000 records? Process 200 and reschedule yourself for one minute later. Long tasks jam the shared queue for everyone.
- Log outcomes somewhere. The core problem with WP-Cron is silence — so don't add to it. A timestamped "last ran / result" option costs nothing and turns "did it run?" from archaeology into a lookup.
- For high-volume or must-not-lose jobs, use Action Scheduler. It's the library WooCommerce built for exactly this: database-backed job tracking with retries, logging, and an admin UI, running on top of the same cron heartbeat. Where WP-Cron entries are fire-and-forget, Action Scheduler jobs are accountable.
Monitoring: closing the silence for good
Even with real cron, the honest final step is external verification — because the failure mode you haven't imagined yet will also be silent. The pattern is called a dead man's switch: your critical task's last step pings a monitoring URL (several services offer this cheaply or free), and the monitor alerts you if the ping stops arriving. Note the inversion: you're not alerting on failure — failures can't announce themselves — you're alerting on the absence of success. For a backup job, this is the difference between discovering a three-week gap during a crisis and getting an email the first morning something's wrong.
The short version
WP-Cron is a scheduler that runs on visitor traffic, fails without errors, and stores its entire world in one database row. It's adequate for busy sites with tolerant schedules and quietly treacherous for everything else. The fix is cheap and permanent: disable the traffic trigger, drive the queue with a real system cron every five minutes, use Crontrol or WP-CLI to inspect and prune the schedule, and put a dead man's switch on anything you'd hate to discover didn't run. An hour of setup, and an entire category of "why didn't it..." mysteries disappears from your life.