WordPress cron not running: why scheduled posts fail and how to fix

Was this helpful?

If you’ve ever watched a “scheduled” WordPress post sail past its publish time like it missed a meeting invite, you’ve met WP-Cron in its natural habitat: “best effort.” Sometimes it works for months. Then traffic dips, a cache changes behavior, or PHP-FPM gets grumpy—and suddenly your content calendar is a lie.

This isn’t a mystical WordPress problem. It’s a scheduling system built on web requests, running inside a stack that loves to optimize away the very thing it needs: a real, reliable trigger. Let’s make it boring again.

WP-Cron is not cron (and that’s the whole problem)

Linux cron is a scheduler. It runs regardless of whether your site gets traffic. WP-Cron is an application-level scheduler that runs because your site gets traffic. That single difference explains most “missed schedule” incidents.

WP-Cron is triggered when WordPress receives a request and notices scheduled tasks are due. It then makes a “loopback” HTTP request to wp-cron.php (or runs it inline, depending on config and runtime). In other words, WordPress schedules work, but it doesn’t own the clock. Your visitors do.

When traffic is steady and the stack is permissive, WP-Cron is “fine.” When traffic is low, or requests are cached, or loopbacks are blocked, WP-Cron doesn’t fire. Scheduled posts don’t publish. WooCommerce subscriptions don’t renew. Security scans don’t run. And you learn what your business actually depends on.

There’s also a reliability smell here: WP-Cron lives in PHP, behind web servers, caches, WAFs, and timeouts. If your cron runner is coupled to the same path that serves end users, you’ve built a scheduler that can be disrupted by… serving end users.

Dry truth: if scheduled publishing matters, you want a real scheduler (system cron or a job runner) invoking WordPress on a known cadence. WP-Cron is a fallback. Treat it like one.

Joke #1: WP-Cron is like a kitchen timer that only rings if someone opens the fridge.

How it fails in production: symptoms you actually see

Failures rarely show up as a clean “cron is broken” alert. They show up as business-facing weirdness. The trick is mapping symptoms to failure modes quickly.

Classic symptoms

  • Scheduled posts stuck in “Scheduled” or “Missed schedule.” The publish time passes; nothing happens.
  • WooCommerce “Scheduled Actions” queue grows. Subscriptions, abandoned cart emails, stock syncs, webhooks—silently delayed.
  • Backups don’t run. Plugins that rely on WP-Cron won’t get CPU time.
  • Search index / sitemap updates lag. SEO tooling relies on scheduled jobs; you get stale output.
  • Random bursts of background work. A sudden traffic spike triggers a backlog, causing slow requests and timeouts.

What’s usually happening under the hood

  • Loopback request blocked or broken. WordPress can’t call itself due to DNS, firewall, WAF, auth, TLS, or redirects.
  • Requests never reach WordPress. Full-page cache serves responses without hitting PHP, so WP-Cron never gets the “tick.”
  • PHP worker starvation. Even if triggered, cron can’t run because FPM workers are busy.
  • Timeouts. Cron starts, gets killed, leaves lock/transient state, and nothing runs until the lock expires.
  • Clock issues. Time drift or mismatched timezone settings confuse scheduling.

Fast diagnosis playbook

This is the order I check things when the business says “scheduled posts didn’t publish” and I want an answer before the next standup.

First: is WordPress trying to schedule events?

  1. List due cron events with WP-CLI.
  2. If the queue is empty, the issue might be the plugin/theme scheduling logic—or timezone confusion.
  3. If the queue is full of overdue events, the runner isn’t executing.

Second: can the site loop back to itself?

  1. From the server, curl wp-cron.php and check HTTP code and latency.
  2. From WordPress itself, loopback tests can fail due to DNS or blocked egress.
  3. Check for redirects to login, forced HTTPS issues, or WAF challenges.

Third: is PHP even available to run it?

  1. Check PHP-FPM status: max children reached, slow logs, backlog.
  2. Check web server error logs for upstream timeouts and 502/504 spikes.
  3. Confirm OPcache and autoload are healthy (cron often exercises rarely-hit code paths).

Then: remove “traffic” from the equation

  1. Disable WP-Cron triggers and run WordPress cron via system cron every minute or five minutes.
  2. Re-check overdue events; they should drain predictably.

This playbook is biased toward finding the bottleneck quickly. It is not polite. It works.

Practical tasks: commands, outputs, and decisions (12+)

These are runnable commands for a typical Linux host running Nginx/Apache + PHP-FPM and WordPress. Adjust paths and users, but keep the intent. Each task includes: the command, example output, what it means, and the decision you make.

Task 1: Confirm the site’s time and timezone at the OS level

cr0x@server:~$ timedatectl
               Local time: Sat 2025-12-27 14:31:19 UTC
           Universal time: Sat 2025-12-27 14:31:19 UTC
                 RTC time: Sat 2025-12-27 14:31:18
                Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

What it means: OS clock is UTC and synced. Good. If this shows System clock synchronized: no or a weird timezone, scheduled posts can drift or appear “missed.”

Decision: If NTP isn’t active/synced, fix time first. Debugging cron on a drifting clock is like chasing a moving target.

Task 2: Check WordPress timezone and current time via WP-CLI

cr0x@server:~$ cd /var/www/html
cr0x@server:/var/www/html$ wp option get timezone_string
America/New_York
cr0x@server:/var/www/html$ wp eval 'echo "wp_time: ".wp_date("c").PHP_EOL;'
wp_time: 2025-12-27T09:31:33-05:00

What it means: WordPress uses a site timezone setting. If it’s blank, WordPress may rely on UTC offset and DST behavior can get weird.

Decision: Prefer a named timezone (like America/New_York) over a raw offset. It’s less surprising around DST.

Task 3: Is WP-CLI talking to the right WordPress?

cr0x@server:/var/www/html$ wp core version
6.7.1
cr0x@server:/var/www/html$ wp site list
+----+---------------------+----------------------------+
| id | url                 | last_updated               |
+----+---------------------+----------------------------+
| 1  | https://example.com | 2025-12-27 14:29:05 +0000  |
+----+---------------------+----------------------------+

What it means: You’re operating on the intended install and (if multisite) the right network.

Decision: If this errors, fix permissions or --path issues before continuing. Blind debugging is how you “fix” staging.

Task 4: List cron events and find overdue ones

cr0x@server:/var/www/html$ wp cron event list --fields=hook,next_run,recurrence --format=table | head
+-------------------------------+---------------------+------------+
| hook                          | next_run            | recurrence |
+-------------------------------+---------------------+------------+
| wp_version_check              | 2025-12-27 13:55:00 | twice_daily|
| wp_scheduled_delete           | 2025-12-27 14:00:00 | daily      |
| action_scheduler_run_queue    | 2025-12-27 14:01:00 | every_minute|
| wp_update_plugins             | 2025-12-27 12:10:00 | twice_daily|
+-------------------------------+---------------------+------------+

What it means: If next_run is in the past by minutes/hours and it’s not executing, the cron runner is broken or blocked.

Decision: If events are overdue, move to loopback and runner checks. If no events are scheduled at all, investigate plugins/theme or scheduling logic.

Task 5: Run cron now, manually, and watch what happens

cr0x@server:/var/www/html$ wp cron event run --due-now
Success: Executed a total of 12 cron events.

What it means: WordPress can execute events when asked. That suggests the problem is the trigger/runner, not the event handlers themselves.

Decision: If manual execution works, implement system cron and disable WP-Cron triggers. If manual execution fails with fatals, go to PHP logs and plugin isolation.

Task 6: Check whether DISABLE_WP_CRON is set

cr0x@server:/var/www/html$ grep -n "DISABLE_WP_CRON" wp-config.php
91:define('DISABLE_WP_CRON', true);

What it means: WP-Cron is disabled. That’s fine only if you have a real system cron job calling it.

Decision: If it’s true and there’s no system cron configured, you found the bug. Add a system cron job (see fixes).

Task 7: Check system cron for a WordPress runner

cr0x@server:~$ sudo crontab -l
no crontab for root

What it means: Root has no cron. The runner may exist under the web user or in /etc/cron.d.

Decision: Check the relevant user and system cron directories.

cr0x@server:~$ crontab -l
# m h  dom mon dow   command
*/5 * * * * cd /var/www/html && wp cron event run --due-now --quiet

What it means: There is a runner, every 5 minutes, using WP-CLI. That’s a sane baseline.

Decision: If scheduled posts are still missing, the runner may not execute (permissions, PATH, PHP), or jobs are failing.

Task 8: Verify cron actually runs (inspect syslog/journal)

cr0x@server:~$ sudo journalctl -u cron --since "1 hour ago" | tail -n 10
Dec 27 14:25:01 server CRON[21901]: (cr0x) CMD (cd /var/www/html && wp cron event run --due-now --quiet)
Dec 27 14:30:01 server CRON[22188]: (cr0x) CMD (cd /var/www/html && wp cron event run --due-now --quiet)

What it means: Cron daemon is executing the job on schedule.

Decision: If you don’t see entries, cron may be stopped, masked, or logs go elsewhere. Fix the platform first.

Task 9: Confirm the wp-cli command works in cron’s environment

cr0x@server:~$ env -i HOME=/tmp PATH=/usr/bin:/bin bash -lc 'cd /var/www/html && wp cron event run --due-now'
Success: Executed a total of 0 cron events.

What it means: Even with a minimal environment (similar to cron), WP-CLI can run. “0 events” can be fine if nothing is due.

Decision: If this fails, hardcode paths in crontab and ensure WP-CLI is installed where cron can find it.

Task 10: Test loopback access to wp-cron.php from the server

cr0x@server:~$ curl -I -sS https://example.com/wp-cron.php?doing_wp_cron=1 | head -n 5
HTTP/2 200
content-type: text/html; charset=UTF-8
cache-control: no-cache, must-revalidate, max-age=0
date: Sat, 27 Dec 2025 14:32:10 GMT

What it means: The endpoint is reachable and returns 200. Good. If you see 301/302 loops, 403, 503, or a WAF “challenge” page, WP-Cron triggers can fail.

Decision: If loopback fails, fix DNS/TLS/redirect/WAF rules or bypass WP-Cron loopback by using system cron with WP-CLI.

Task 11: See whether full-page cache is preventing PHP hits

cr0x@server:~$ curl -sS -I https://example.com/ | egrep -i 'x-cache|cf-cache-status|age|server|via'
server: nginx
x-cache: HIT
age: 1842

What it means: Requests can be served without touching PHP. If your site is low traffic and mostly cached, WP-Cron may barely get triggered.

Decision: Don’t rely on visitors to drive scheduling. Use system cron. Optionally exclude /wp-cron.php from caching and rate limiting.

Task 12: Check PHP-FPM pressure (worker starvation)

cr0x@server:~$ sudo tail -n 20 /var/log/php8.2-fpm.log
[27-Dec-2025 14:20:55] WARNING: [pool www] server reached pm.max_children setting (20), consider raising it
[27-Dec-2025 14:20:56] WARNING: [pool www] child 18734, script '/var/www/html/wp-cron.php' (request: "GET /wp-cron.php?doing_wp_cron=...") executing too slow (12.345 sec), logging

What it means: Cron is trying to run, but PHP is saturated or slow. WP-Cron requests can time out or never start.

Decision: Increase capacity (pm.max_children), reduce request load, move cron to a separate pool, or offload heavy jobs.

Task 13: Look for WordPress fatal errors during cron

cr0x@server:~$ sudo tail -n 30 /var/log/nginx/error.log
2025/12/27 14:21:03 [error] 1229#1229: *9981 FastCGI sent in stderr: "PHP Fatal error:  Uncaught Error: Call to undefined function mb_strlen() in /var/www/html/wp-includes/formatting.php:..."
2025/12/27 14:21:03 [error] 1229#1229: *9981 upstream prematurely closed FastCGI stdout while reading response header from upstream, client: 127.0.0.1, server: example.com, request: "GET /wp-cron.php?doing_wp_cron=..."

What it means: Cron died on a PHP fatal (missing extension in this example). That can leave events overdue indefinitely.

Decision: Fix the underlying PHP runtime. Cron isn’t special; it hits code paths normal page views might not.

Task 14: Identify the specific hook that’s piling up (Action Scheduler)

cr0x@server:/var/www/html$ wp action-scheduler list --status=pending --per-page=5
+----------+------------------------------+---------------------+----------+
| hook     | args                         | scheduled_date_gmt   | status   |
+----------+------------------------------+---------------------+----------+
| wc_email | {"id":12345,"type":"follow"} | 2025-12-27 13:58:00 | pending  |
| wc_email | {"id":12346,"type":"follow"} | 2025-12-27 13:59:00 | pending  |
| wc_email | {"id":12347,"type":"follow"} | 2025-12-27 14:00:00 | pending  |
+----------+------------------------------+---------------------+----------+

What it means: WooCommerce’s Action Scheduler is backlogged. This can be “cron isn’t running” or “cron is running but can’t keep up.”

Decision: Run the queue manually to test, then scale the runner or fix performance.

Task 15: Run Action Scheduler queue manually (controlled test)

cr0x@server:/var/www/html$ wp action-scheduler run --batch-size=25
Processed 25 actions.

What it means: Jobs can execute. If this is slow or errors, you’ve got a workload/performance or plugin bug, not just a trigger issue.

Decision: If manual runs succeed, schedule a proper runner. If it fails, isolate the plugin/hook and fix root cause.

Task 16: Verify the database isn’t the hidden bottleneck (locks, slow queries)

cr0x@server:~$ sudo mysql -e "SHOW PROCESSLIST;" | head
Id	User	Host	db	Command	Time	State	Info
42	wpuser	localhost	wordpress	Query	12	Waiting for table metadata lock	UPDATE wp_options SET option_value='...' WHERE option_name='cron'
77	wpuser	localhost	wordpress	Query	2	Sending data	SELECT * FROM wp_actionscheduler_actions WHERE status='pending' LIMIT 0, 25

What it means: Cron updates (stored in wp_options) can get blocked by metadata locks. Heavy schema changes, long transactions, or bad queries can stall scheduling.

Decision: If you see lock waits, find the blocker, reduce long transactions, and avoid running DDL during peak. Cron relies on quick option writes.

Fixes that work: from quick band-aids to real scheduling

Fix 1: Stop relying on traffic — use system cron + WP-CLI

This is the grown-up fix. If you’re on a VPS, bare metal, or any host where you control cron, do it.

  1. Disable the WP-Cron trigger by adding this to wp-config.php:
    cr0x@server:~$ sed -n '1,120p' /var/www/html/wp-config.php | tail -n 5
    define('DB_COLLATE', '');
    define('DISABLE_WP_CRON', true);
    /* That's all, stop editing! Happy publishing. */
    
  2. Add a crontab entry (every 1–5 minutes depending on workload):
    cr0x@server:~$ crontab -e
    */1 * * * * cd /var/www/html && /usr/local/bin/wp cron event run --due-now --quiet
    

Why WP-CLI over hitting wp-cron.php? Fewer moving parts. No WAF. No TLS. No redirects. No cache. Just PHP executing WordPress in a controlled context.

Trade-off: You must keep WP-CLI installed and accessible. That is not hard. It’s a file.

Fix 2: If you can’t run WP-CLI, run wp-cron.php from cron anyway

Sometimes you’re on a locked-down shared host or a platform where WP-CLI is missing. You can still use system cron to call the endpoint, but you’re back to HTTP fragility.

cr0x@server:~$ crontab -e
*/2 * * * * curl -sS -o /dev/null https://example.com/wp-cron.php?doing_wp_cron=1

Decision: Use this only if you must. If it fails intermittently due to WAF/rate-limits, it will quietly reintroduce the problem.

Fix 3: Fix loopback failures (403/401/redirects/WAF)

Loopback failures are the most annoying because the site works fine for humans. Only the site calling itself fails.

  • Basic auth on staging or production: Loopback gets 401. Fix by allowing loopback IPs or configure cron to use WP-CLI.
  • Forced HTTPS / redirect loops: Wrong siteurl/home or proxy headers. Fix your reverse proxy config and WordPress URL options.
  • WAF/bot protection: Challenges block non-browser requests. Exempt /wp-cron.php or stop using HTTP loopback entirely.

Fix 4: Exclude wp-cron.php from caching and aggressive rate limiting

Even if you run cron via system cron, you may still have plugins that trigger loopback. Make cron endpoints “boring.”

At minimum, do not cache /wp-cron.php and avoid applying bot challenges to it. If your CDN insists on being “helpful,” it will eventually help you miss a schedule.

Fix 5: Separate PHP capacity for cron (serious sites do this)

If your WordPress front-end is busy, cron is competing for the same workers. Under load, cron becomes a starvation victim. Or worse: cron starts and makes the front-end slower. Pick your poison.

Options:

  • Dedicated PHP-FPM pool for admin and cron paths, with its own limits.
  • Separate runner host/container that calls WP-CLI against the same codebase and database.
  • Queue heavy tasks out of WordPress entirely (email sending, exports, feeds) and let WP only enqueue.

Fix 6: Deal with “cron lock” and stuck transients

WordPress uses a locking mechanism (stored in options/transients) to avoid concurrent cron runs. If a process dies mid-run, you can get the “cron is locked” pattern where nothing runs until a timeout window passes.

Prefer diagnosing why cron is dying (fatals, memory, timeouts), but when you need to unstick production:

cr0x@server:/var/www/html$ wp option list --search=cron --fields=option_name,autoload --format=table | head
+----------------+----------+
| option_name    | autoload |
+----------------+----------+
| cron           | yes      |
+----------------+----------+

Decision: Don’t delete the cron option; that’s the schedule store. If you suspect a stale lock, address the doing_cron transient if present, but only after confirming a run isn’t active.

Fix 7: Make scheduled publishing resilient (don’t bet the business on one mechanism)

If publishing on time is revenue-critical (ads, campaigns, legal notices), consider belt-and-suspenders:

  • System cron runs every minute.
  • An external monitor checks for overdue posts and pages SRE/ops if it happens again.
  • Editors get UI warnings for missed schedules (not a fix, but a faster signal).

Quote (paraphrased idea): Werner Vogels has repeatedly pushed the idea that “everything fails, all the time,” so systems must be designed to expect and tolerate failure.

Joke #2: If your incident response plan is “refresh the homepage until it publishes,” congratulations—you’ve invented human-powered cron.

Common mistakes: symptom → root cause → fix

This is the part where we stop being polite to our past selves.

1) Scheduled posts never publish on low-traffic sites

Symptom: Posts scheduled overnight don’t publish until someone visits the site.

Root cause: WP-Cron depends on page loads to trigger.

Fix: Disable WP-Cron triggers and run system cron with WP-CLI every 1–5 minutes.

2) “Missed schedule” spikes after enabling a new cache/CDN

Symptom: Everything was fine; then caching was enabled and scheduled events stopped.

Root cause: Cache serves pages without PHP, removing the WP-Cron “tick.” Sometimes wp-cron.php itself gets cached or rate-limited.

Fix: Use system cron. Exempt /wp-cron.php from caching and WAF challenges.

3) wp-cron.php returns 403 only from the server

Symptom: Browsers get 200; loopback from server gets 403 or a challenge page.

Root cause: WAF rules treat server-originated requests as bots; or egress IP differs; or headers don’t match expected patterns.

Fix: Allowlist the server’s egress IP for /wp-cron.php, or stop using loopback and run WP-CLI from cron.

4) Cron runs, but backlog never drains

Symptom: Cron events show overdue, manual run executes a few, but the pile remains.

Root cause: Workload is too heavy for the cadence/capacity; Action Scheduler needs more throughput; PHP timeouts are killing runs mid-batch.

Fix: Increase runner frequency, batch sizes cautiously, add PHP capacity, and investigate slow hooks. Consider separate worker pool.

5) DISABLE_WP_CRON set without a real runner

Symptom: Everything scheduled stops after a “performance tuning” change.

Root cause: Someone disabled WP-Cron to “reduce requests” and forgot the system cron replacement.

Fix: Add system cron with WP-CLI; verify via logs that it executes.

6) Loopback breaks due to wrong site URLs after migration

Symptom: Cron endpoint redirects repeatedly or hits the wrong domain.

Root cause: home/siteurl mismatched, or reverse proxy headers not set, causing WordPress to generate bad internal URLs.

Fix: Correct URL options, fix proxy headers, and retest curl to wp-cron.php.

7) Cron fails only during peak traffic

Symptom: Overnight is fine; launches and sales cause missed schedules.

Root cause: PHP-FPM saturation; cron requests are queued or killed.

Fix: Separate cron into its own pool/host, increase FPM capacity, reduce expensive plugins, and keep cron workload bounded.

8) “Random” failures traced to DNS

Symptom: wp-cron.php loopback fails intermittently with timeouts.

Root cause: Server resolves the public domain to an external IP, hairpins through a firewall/CDN, or hits IPv6 issues; sometimes internal DNS differs from public.

Fix: Use WP-CLI runner. If you must loop back, fix DNS/resolution and prefer direct localhost invocation patterns.

Three corporate mini-stories from the land of “it should have worked”

Mini-story #1: The wrong assumption (“Cron runs because traffic exists”)

The marketing team at a mid-size SaaS company moved their WordPress blog behind a shiny new CDN. Page speed improved. Lighthouse scores got bragged about. Everyone won.

Two weeks later, a scheduled post announcing a webinar didn’t publish. Someone noticed an hour after start time. The immediate theory was editorial error: “They forgot to hit Publish.” The second theory was timezones: “Maybe it’s in UTC?” Classic blame roulette.

On the server, the WordPress cron queue showed overdue events. Manually running wp cron event run --due-now published the post instantly. That was the clue: the system could execute; it just wasn’t being triggered.

The wrong assumption was subtle: “We have traffic, therefore WP-Cron will run.” But the CDN served most pages from cache. PHP wasn’t being hit. The visitors were real; their requests never touched WordPress. WP-Cron had been effectively unplugged.

The fix was simple and slightly embarrassing: disable WP-Cron triggers, install WP-CLI, and run system cron every minute. After that, publishing became a function of time again, not page views.

Mini-story #2: The optimization that backfired (“Disable wp-cron.php for performance”)

An ops team inherited a WordPress cluster that had occasional latency spikes. Somebody found blog posts claiming wp-cron.php is “slow” and decided to optimize. They blocked access to /wp-cron.php at the edge and set DISABLE_WP_CRON in wp-config.php.

In isolation, that’s not insane. But they didn’t add a system cron replacement. The site looked fine because the front-end still served content. The first visible failure happened days later: WooCommerce subscriptions didn’t renew on time, and a pile of “pending” actions accumulated.

When they finally investigated, they found a queue backlog large enough to turn “enable cron” into a mini DDoS of their own making. The first re-enabled run tried to execute hours of overdue tasks under peak load. PHP-FPM workers hit their limits. Customers got 502s. The fix caused a second incident.

The resolution wasn’t just “turn cron back on.” They introduced a controlled runner: WP-CLI cron every minute, Action Scheduler worker cadence tuned, and a cap on batch sizes. They also learned to treat “disable” changes as production changes with rollback plans.

Optimization is great. Optimization without a replacement plan is just a slower incident.

Mini-story #3: The boring practice that saved the day (separate runner + monitoring)

A publisher with multiple WordPress sites had been burned before. They didn’t trust WP-Cron, so they built a dull, effective setup: each site had DISABLE_WP_CRON enabled and a system cron job running WP-CLI. They logged execution times and counted overdue events as a metric.

One morning, a storage issue on a VM caused I/O latency spikes. The front-end stayed mostly up thanks to caching, but PHP processes slowed down. The cron job still ran, but it started taking longer than expected, and the “overdue events” metric began to climb.

No one waited for editors to complain. The alert fired when overdue events exceeded a threshold for several minutes. The on-call engineer checked logs, saw PHP-FPM slow warnings, and correlated it with disk latency. They shifted cron execution to a less impacted runner host temporarily and throttled background tasks until storage stabilized.

Did scheduled posts publish on time? Mostly, yes. Some were delayed by a few minutes, but nothing missed an hour. The business barely noticed. That’s the goal: failures that happen quietly because your boring controls caught them early.

They didn’t win because they were clever. They won because they were prepared.

Interesting facts and context (why this design exists)

WP-Cron looks odd if you come from traditional systems. It’s less odd when you remember the environments WordPress originally targeted.

  1. WP-Cron exists largely because shared hosting didn’t reliably offer cron access. WordPress had to schedule tasks without assuming OS-level privileges.
  2. It’s “pseudo-cron” by design. It piggybacks on page requests to approximate time-based scheduling.
  3. WordPress stores cron schedules in the database (in wp_options). That makes it portable, but also makes it sensitive to DB locks and slow writes.
  4. The loopback request is both a feature and a liability. It avoids running heavy work during the user’s request, but it depends on HTTP working internally.
  5. Some plugins bypass the loopback and run tasks during normal page loads. That can “work” until it causes latency spikes under load. Reliability versus performance, the eternal trade.
  6. WooCommerce’s Action Scheduler became common because WP-Cron alone was too blunt for heavy workloads. It adds persistence, retries, and visibility—but still needs a runner.
  7. The “missed schedule” symptom is often not the post scheduling logic. It’s the runner failing to execute at the publish timestamp, leaving posts in limbo until the next cron tick.
  8. Modern caching makes WP-Cron less reliable than it was in 2008. Caches reduce PHP hits, and WP-Cron depends on PHP hits to trigger.
  9. Reverse proxies and HTTPS enforcement introduced new loopback failure modes. Wrong headers or mixed URL settings can cause internal redirects that only cron sees.

Checklists / step-by-step plan

Checklist A: Immediate triage (15 minutes)

  1. Confirm OS time sync (timedatectl). If not synced, fix NTP first.
  2. List due cron events (wp cron event list). If overdue, runner is broken.
  3. Manually run due events (wp cron event run --due-now) to restore service.
  4. Check whether WP-Cron is disabled (grep DISABLE_WP_CRON).
  5. Curl /wp-cron.php and capture the HTTP code. 200 is good; anything else is a clue.
  6. Look at PHP-FPM and web error logs for timeouts/fatals around cron attempts.

Checklist B: Permanent fix for most sites (60–90 minutes)

  1. Install WP-CLI in a stable location (package manager or verified phar), ensure it runs as the right user.
  2. Set define('DISABLE_WP_CRON', true); in wp-config.php.
  3. Create a system cron job running WP-CLI every minute or five minutes.
  4. Verify execution via journalctl -u cron and confirm overdue events drain.
  5. If WooCommerce/Action Scheduler exists, validate queue health (wp action-scheduler list).
  6. Exclude /wp-cron.php from caching/WAF challenges anyway. Other plugins may still hit it.

Checklist C: Hardening for high-load or revenue-critical sites

  1. Separate cron execution capacity (dedicated PHP-FPM pool or runner host).
  2. Measure overdue events and queue depth; alert when thresholds are exceeded.
  3. Bound runtime: tune batch sizes, avoid “run everything” patterns that cause thundering herds.
  4. Audit scheduled tasks: remove redundant jobs, reduce frequency, and avoid heavy work inside page requests.
  5. Plan for failure: a backlog replay plan that won’t crush your front-end.

FAQ

1) Why do scheduled posts fail when the site has no visitors?

Because WP-Cron is triggered by site requests. No requests, no trigger. Use system cron (or a job runner) to invoke WordPress on a schedule.

2) Should I disable WP-Cron?

Yes—if you replace it with system cron. Disabling without a replacement is a clean way to break scheduled posts and background tasks.

3) Is calling wp-cron.php from system cron good enough?

It can work, but it’s fragile (HTTP, WAF, TLS, redirects). WP-CLI is more reliable because it avoids the whole web path.

4) My wp-cron.php returns 200. Why are tasks still overdue?

200 only means the endpoint responds. Cron can still fail due to PHP fatals, timeouts, database locks, or worker starvation. Check logs and run wp cron event run --due-now to see if execution works.

5) Does caching break WP-Cron?

Often, yes indirectly. Full-page caching reduces PHP hits, which reduces WP-Cron triggers. It can also directly interfere if /wp-cron.php is cached or rate-limited.

6) What about WooCommerce “Scheduled Actions”?

That’s Action Scheduler. It’s more visible than vanilla WP-Cron but still needs a runner. Use WP-CLI to run it and ensure your cron cadence and capacity can drain the queue.

7) Can timezones cause “missed schedule”?

Yes, especially with DST and misconfigured site timezone settings. Confirm OS time sync, then confirm the WordPress timezone and current time via WP-CLI.

8) How frequently should system cron run?

Common choices are every minute for busy sites with lots of queued work, or every five minutes for lighter sites. The right answer is: frequently enough that “overdue” stays near zero.

9) Is WP-Cron a security risk?

Not inherently, but exposing wp-cron.php to the internet can attract noisy traffic. If you use WP-CLI with system cron, you can keep it simple and reduce external dependence.

10) What if I’m on managed hosting and can’t use system cron?

Use whatever scheduled task facility the host provides. Many have “cron jobs” in a panel or a scheduler API. If all you can do is HTTP, call /wp-cron.php and exempt it from WAF/caching.

Conclusion: next steps that prevent repeat incidents

WordPress scheduled posts fail for the same reason most production systems fail: hidden assumptions. The assumption here is that time-based work will run because web traffic exists and internal HTTP will behave. That’s not engineering; that’s hope with a CMS.

Do the practical thing:

  1. Check the queue and run due events manually to restore service.
  2. Stop relying on WP-Cron triggers. Use system cron with WP-CLI.
  3. Harden the path: exclude /wp-cron.php from caching/WAF, and separate cron capacity if you have load.
  4. Add one monitor: overdue events or Action Scheduler backlog. Catch the next failure before your editors do.

Make scheduling boring. Your content calendar deserves better than “someone visited the homepage at the right time.”

← Previous
Ubuntu 24.04 SSD/NVMe performance falls over time: prove it’s TRIM/GC and fix it
Next →
Google Glass: when the future felt awkward in public

Leave a comment