How to Keep WordPress Cron Reliable When Traffic Is Low
WordPress cron is not running on a schedule in the way most people assume. It is a pseudo cron: nothing on the server wakes up at the scheduled time. Instead, the schedule is stored in the database, and the work only happens when a page request arrives and WordPress checks whether anything is due. If nobody visits, nothing runs. That single design decision explains nearly every "wordpress cron not running" report on a quiet site.
The mechanism is worth knowing precisely, because the fix depends on it. Each scheduled event lives in the cron option in the wp_options table, keyed by a timestamp. On every request, early in the load, WordPress calls a function that looks at the next due timestamp. If an event is due, it sends an internal loopback HTTP request to wp-cron.php and returns immediately, so the visitor never waits for the task itself. That loopback request is the actual worker. It runs as a separate PHP process, claims the due events, executes them, and reschedules the recurring ones.
Why low traffic breaks the schedule
The loopback request only fires when a normal request arrives after an event becomes due. On a busy site that is a matter of seconds. On a site with a handful of visits a day, or none overnight, a task scheduled for the small hours simply waits until someone shows up. That is not a failure, it is latency, but it produces the classic symptoms: a daily backup that runs at an unpredictable hour, a digest email that arrives late, a cleanup job that skips a window entirely.
There is a second, quieter failure that looks identical. The loopback request is made from the server to itself, and on some configurations it cannot complete. A reverse proxy that does not route the request correctly, a firewall blocking outbound connections to the site's own hostname, HTTP authentication on a staging environment, or DNS that resolves the domain to an external address all break it. WordPress still marks the attempt, but the work never happens. The site looks healthy and the tasks silently pile up as overdue.
You can see whether the loopback path works at all by requesting it directly and watching the response headers. A working wp-cron.php answers quickly with a small body and no redirect.
curl -sS -D - -o /dev/null https://example.com/wp-cron.php
HTTP/2 200
content-type: text/html; charset=UTF-8
content-length: 0
A 301, a 401, a 403, or a request that hangs tells you the loopback is the problem rather than the schedule. Some setups return a 200 but with a full theme page, which means the request was rewritten and the cron file was never reached.
How to check whether WordPress cron is actually running
Before changing anything, confirm the state of the queue. The fastest route on a server you control is the command line, using the project's own tooling. Listing the scheduled events shows each hook, its next run timestamp, and its recurrence.
wp cron event list --fields=hook,next_run_relative,recurrence
+----------------------+-------------------+------------+
| hook | next_run_relative | recurrence |
+----------------------+-------------------+------------+
| wp_version_check | 3 hours | 12 hours |
| myplugin_daily_sync | now | 24 hours |
+----------------------+-------------------+------------+
A hook showing now repeatedly, across several checks minutes apart, is not running. It is due and staying due. You can also run the queue by hand to see whether the tasks themselves succeed, which separates a scheduling problem from a failing job:
wp cron event run --due-now
If that executes cleanly, the code is fine and the trigger is the problem. If it errors, you have a task bug on top of a scheduling issue, and you should fix the task first.
Replace the pseudo cron with a real one
The standard fix is to stop relying on page views and call wp-cron.php from the system scheduler. First, disable the automatic trigger so you are not running both. Add this to wp-config.php, above the line that requires wp-settings.php:
define( 'DISABLE_WP_CRON', true );
Then add a system cron entry that requests the file on a fixed interval. The interval should be shorter than your shortest task recurrence, and it does not need to be aggressive. Every five minutes is common and is far gentler than the request-driven model, because it produces a predictable, bounded number of calls regardless of traffic.
*/5 * * * * curl -sS https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
Two details matter here. Pass the loopback query parameter, or use a request that the site treats as internal, so the run is identified as a cron invocation. And prefer a method that avoids DNS and TLS where you can: calling the file through the PHP binary or through the local web server on 127.0.0.1 removes the outbound network dependency that broke the loopback in the first place. If the site sits behind a proxy or a CDN, make sure the cron URL is excluded from caching, or you will serve a cached empty response and run nothing.
Once the system cron is in place, confirm it with the same event list. Due timestamps should now clear within one interval. If tasks still sit at now, check the system cron log and the web server access log for the wp-cron.php requests; their absence is conclusive.
Keeping it dependable without hammering the server
The temptation after fixing a missed schedule is to run cron every minute and to run every task on every pass. Resist both. The system cron already decouples execution from traffic, so the interval only needs to be tight enough for your most time-sensitive task. Long-running jobs such as imports or large mail batches should be chunked, so each run does a bounded amount of work and returns, rather than holding a PHP process open. If a task is genuinely heavy, give it its own schedule and its own lock so overlapping runs cannot stack up.
Also watch for tasks that reschedule themselves on failure. A recurring event that throws before completing can leave the queue in a state where the same hook is due on every pass, which is exactly the load you were trying to avoid. Logging the outcome of each run, and alerting when a hook stays due across two intervals, catches this early. The goal is a schedule you can reason about: a fixed trigger, a bounded set of tasks, and a log that tells you when one of them stops completing.
Next, check the current state of your own site before changing anything: run the event list, note which hooks are stuck at now, and request wp-cron.php directly to see whether the loopback works. That tells you whether you are fixing a trigger or a task. Then disable the built in trigger, add the system cron entry, and verify against the same event list a few intervals later. If you manage several sites, standardise the interval and the logging across them so a silent failure is visible rather than discovered months later.
