Guide

WordPress wp-cron disabled (DISABLE_WP_CRON=true) — how to verify your real cron actually works

You set DISABLEWPCRON=true in wp-config.php months ago and added a system crontab entry to call wp-cron.php directly. Everything looked fine. Page loads got faster. You moved on.

1. Problem

You set DISABLE_WP_CRON=true in wp-config.php months ago and added a system crontab entry to call wp-cron.php directly. Everything looked fine. Page loads got faster. You moved on.

Then a customer asks why their order confirmation email arrived six hours late. You check Tools → Site Health — green. The wp_options table — cron array exists, looks normal. You SSH in and run crontab -l — yes, there's a line that calls wp-cron.php every five minutes. Everything looks correct.

But scheduled posts are stuck at "Missed schedule." Newsletters never went out. Cleanup jobs from your security plugin haven't run since last Tuesday. WooCommerce subscription renewals are failing silently.

This is the exact failure people search for as DISABLE_WP_CRON true verify cron working — and the standard advice ("just add a crontab entry") is the advice that caused the problem. You need a way to verify your real cron is actually firing, and WordPress itself will not tell you. The only reliable signal is the absence of a cron.run heartbeat in your logs.

2. Impact

When DISABLE_WP_CRON=true is set and the replacement system cron silently fails, every scheduled task in your WordPress install stops. This is a complete halt of background work, with zero user-facing error.

Concrete consequences in production:

  • Scheduled posts never publish. Editorial calendars break. Embargoed news leaks because authors revert to "publish now."
  • Transactional email queues stall. WP Mail SMTP, MailPoet, WooCommerce often defer sending to cron. Order confirmations, password resets, and abandoned-cart emails arrive hours late or never.
  • Security plugin scans stop running. Wordfence, Sucuri, iThemes — all rely on cron. A site that hasn't scanned in a week doesn't know it's been compromised.
  • WooCommerce subscription renewals fail. Subscriptions go to on-hold because the renewal job never fires. You lose MRR silently until customers complain.
  • Backups don't run. UpdraftPlus, BackWPup — if cron is dead, your backup window is dead too.
  • Transients never expire. Your database fills with stale _transient_* rows because cleanup never fires.

The damage is asymmetric: the failure costs nothing to introduce (one config line plus a forgotten crontab) and can cost weeks of revenue and trust before anyone notices. This is the canonical silent failure class.

3. Why It’s Hard to Spot

Several reasons this slips past standard checks:

  • Tools → Site Health does not test external cron. It checks whether wp-cron.php is reachable via loopback and whether the cron array is populated. Both can be true while no system cron has fired in days.
  • Uptime monitors test the homepage. Pingdom, UptimeRobot, StatusCake — they hit / and get 200. Cron is irrelevant to their probes.
  • The wp_options.cron array looks healthy. Events are queued with future timestamps. Without running them, those timestamps just slide into the past.
  • crontab -l lies. The line exists, but the user may lack permissions on wp-cron.php, the PHP binary path may be wrong (php vs php8.2), or the working directory may break relative paths in wp-load.php.
  • Hosting providers silently change cron. Managed hosts (Kinsta, WP Engine, Cloudways) often replace system cron with their own scheduler. Migrations leave stale DISABLE_WP_CRON=true with no replacement.
  • Containerized deploys lose cron. WordPress in Docker/ECS without a sidecar cron container has no system crontab at all.
  • The first symptom is always lagged. A scheduled post 2 hours late is the output of the failure, not the failure itself. By the time you notice, you have hours of backlog.

Textbook silent failure: no error, no exception, no alert path — just the quiet absence of work being done.

4. Cause

Default WordPress cron is "poor man's cron" — every front-end request, WordPress checks wp_options.cron for due events and spawns an internal HTTP loopback to wp-cron.php. It is opportunistic: no traffic, no cron.

DISABLE_WP_CRON=true turns off the loopback spawn. Nothing inside WordPress triggers scheduled jobs. You are responsible for hitting wp-cron.php from the outside on a fixed cadence — a system crontab entry or a hosted scheduler.

If your scheduler runs successfully, wp-cron.php executes due events. The Logystera WordPress plugin emits a cron.run signal each time the handler completes — this is the heartbeat. A healthy site emits cron.run at the same cadence as your scheduler, regardless of public traffic.

If the scheduler does not run — wrong path, wrong PHP binary, wrong user, network block, hosting provider silently disabled crontab, container restart that wiped the cron daemon — wp-cron.php is never invoked. WordPress queues events into wp_options.cron but nothing drains the queue. Each unfired event eventually generates a wp.cron type=missed_schedule signal. Email-related jobs accumulate as wp.email signals with elevated delay_seconds.

The diagnostic asymmetry is critical: WordPress logs nothing when cron does not fire. It logs only when cron does fire and a job is missed. If cron never runs at all, the silence is total. The only positive signal you can rely on is the presence of cron.run heartbeats — and the alarm condition is their absence.

The plugin also captures the boot-time environment as a wp.environment signal recording DISABLE_WP_CRON=true. Combining wp.environment.DISABLE_WP_CRON=true with no cron.run heartbeat in the last 15 minutes is the precise fingerprint of this failure.

5. Solution

5.1 Diagnose (logs first)

Stop checking the dashboard. Go to logs. The diagnostic flow is built around the absence of cron.run heartbeats.

Step 1 — Confirm DISABLE_WP_CRON=true is actually set.

wp config get DISABLE_WP_CRON
# expected: 1 (or 'true')

If this returns nothing, internal cron is still active and this guide does not apply. If it returns 1, you are dependent on an external scheduler. This produces the wp.environment signal at plugin boot.

Step 2 — Check whether wp-cron.php has been hit at all.

Look at your web server access log for requests to wp-cron.php:

grep "wp-cron.php" /var/log/nginx/access.log | tail -20

Or for Apache:

grep "wp-cron.php" /var/log/apache2/access.log | tail -20

Healthy output looks like a regular cadence (every 5 minutes, every 15 minutes — whatever you configured) with 200 status codes from 127.0.0.1 or your scheduler's IP. If the most recent hit is hours or days old, your scheduler is not firing. If hits exist but are all 502/504/499, the scheduler is firing but PHP-FPM is timing out — different problem, same symptom.

Step 3 — Check the PHP error log for cron handler output.

grep -i "cron" /var/log/php-fpm/error.log | tail -50
tail -f /var/log/php/wp-errors.log

You're looking for fatals or timeouts during cron execution that would prevent the cron.run signal from being emitted even when the scheduler does fire.

Step 4 — Run the wp-cron handler manually and watch.

sudo -u www-data /usr/bin/php8.2 /var/www/html/wp-cron.php

Use the exact same user, PHP binary, and path that your crontab uses. If this fails (permission denied, PHP module missing, path wrong), your crontab is failing the same way silently.

Step 5 — Inspect the system cron log.

On Debian/Ubuntu:

grep CRON /var/log/syslog | tail -30

On RHEL/CentOS:

journalctl -u crond | tail -50

You want to see the cron daemon executing your wp-cron.php line. If there are no log entries for the www-data (or whichever) user, the crontab is not registered for the right account.

Step 6 — List queued events that have not fired.

wp cron event list --due-now
wp cron event list --field=hook --format=count

A long list of due-now events that never decreases is the definitive sign that the scheduler is not draining the queue. Each row here is a future wp.cron type=missed_schedule signal waiting to happen.

Step 7 — Map the diagnosis back to signals.

| Log query | Signal it surfaces | |---|---| | grep "wp-cron.php" access.log (no recent hits) | absence of cron.run heartbeat | | wp cron event list --due-now (growing) | wp.cron type=missed_schedule | | grep -i "cron" php-fpm/error.log (fatals during run) | php.fatal correlated with absent cron.run | | Email queue plugin showing delayed sends | wp.email with elevated delay_seconds | | wp config get DISABLE_WP_CRON returns 1 | wp.environment.DISABLE_WP_CRON=true |

The combination — DISABLE_WP_CRON=true present, cron.run absent for >15 min, wp.cron type=missed_schedule accumulating — is the unambiguous fingerprint of this failure.

5.2 Root Causes

(see root causes inline in 5.3 Fix)

5.3 Fix

Diagnose which root cause applies before changing anything. The fix depends on why the scheduler is not firing.

Cause A — Crontab points to the wrong PHP binary.

The most common cause. Many systems have multiple PHP versions; php may resolve to PHP 7.4 while WordPress requires 8.x.

Fix:

which php8.2  # or whichever your site uses
# Edit crontab to use absolute path:
*/5 * * * * /usr/bin/php8.2 -q /var/www/html/wp-cron.php > /dev/null 2>&1

Signal: returning cron.run heartbeats; resolved fatals in /var/log/php-fpm/error.log.

Cause B — Crontab installed under the wrong user.

If you ran crontab -e as root but the site files are owned by www-data, the cron job may run with insufficient permissions, or be confused by SELinux/AppArmor.

Fix:

sudo -u www-data crontab -e
# add: */5 * * * * /usr/bin/php8.2 /var/www/html/wp-cron.php

Signal: cron.run returns; no permission-denied entries in syslog.

Cause C — HTTP-based scheduler hitting a wrong URL or auth wall.

If you use curl https://example.com/wp-cron.php from cron, it fails if the URL has changed, HTTP basic auth is in front of staging, Cloudflare is challenging the request, or the TLS chain is broken on the cron host.

Fix: switch to local CLI invocation (php /var/www/html/wp-cron.php) which bypasses HTTP. If you must use HTTP, ensure the request bypasses any WAF/auth rules.

Signal: scheduler hits stop returning 401/403/000 in cron log; cron.run returns.

Cause D — Container deploy with no cron daemon.

WordPress in Docker/Kubernetes/ECS often has no cron at all. The container is stateless and cron was never installed.

Fix: run a separate sidecar that hits the WordPress container on a schedule (Kubernetes CronJob, ECS Scheduled Task, or a supercronic sidecar). Do not install cron inside the WordPress container — it dies on restart.

Signal: scheduled job in orchestrator logs every N minutes; corresponding cron.run per invocation.

Cause E — Managed host's cron toggle is off or misconfigured.

Kinsta, WP Engine and similar provide their own cron UI. After migration, the toggle may be off, or the URL may be wrong.

Fix: log into the host's control panel, confirm a job exists for wp-cron.php at 1–5 minute intervals, verify the next-run timestamp is in the future.

Signal: cron.run returns at host's configured interval.

Cause F — wp-cron.php itself is fataling.

A plugin's scheduled hook throws a fatal, killing the cron run before it completes. The scheduler fires, wp-cron.php starts, dies mid-execution, and no cron.run signal is emitted.

Fix: check php-fpm error log for the fatal, identify the plugin, deactivate or update it. This is php.fatal masquerading as a cron problem.

Signal: php.fatal entries disappear; cron.run emits successfully.

5.4 Verify

Verification requires a positive signal — something must appear, not just something must stop.

The signal that must start appearing: cron.run heartbeat at the cadence you configured (every 5 minutes, every 15 minutes, etc.).

The signals that must stop appearing: new wp.cron type=missed_schedule events, and wp.email events with elevated delay_seconds.

Healthy log pattern (over 30 minutes):

# Web server access log shows scheduler hits at expected interval
tail -100 /var/log/nginx/access.log | grep wp-cron.php
# 192.168.1.10 - - [27/Apr/2026:14:00:01 +0000] "POST /wp-cron.php?doing_wp_cron HTTP/1.1" 200 0
# 192.168.1.10 - - [27/Apr/2026:14:05:01 +0000] "POST /wp-cron.php?doing_wp_cron HTTP/1.1" 200 0
# 192.168.1.10 - - [27/Apr/2026:14:10:01 +0000] "POST /wp-cron.php?doing_wp_cron HTTP/1.1" 200 0

# Due queue drains
wp cron event list --due-now --format=count
# 0

Timeframe: Wait at least three full scheduler intervals (e.g., 15 minutes if cron runs every 5). A single successful cron.run proves nothing — it might fire once and break again. Three consecutive heartbeats at the expected cadence is the minimum confidence threshold.

Tested: Schedule a test event and confirm it fires.

wp cron event schedule my_test_hook now
sleep 360  # wait one cron cycle plus margin
wp cron event list | grep my_test_hook
# should be empty (event ran and was removed)

What "still broken" looks like: wp cron event list --due-now returning a non-empty, growing list 30 minutes after your fix. If that count is climbing, either the scheduler still isn't firing, or wp-cron.php is fataling mid-run. Go back to section 5 step 3.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as cron.run heartbeat (absence of expected heartbeat = silent cron failure).

The technical fix in section 6 is the easy half. The hard half is knowing the failure happened — because as we established in section 4, WordPress emits no error when cron silently dies.

Without a heartbeat-based detection layer, your only feedback loops are:

  • A customer noticing late email (hours to days)
  • A scheduled post failing to publish (hours)
  • Your subscription renewals dropping (days, after billing cycle)

These feedback loops are slow and expensive. The detection that actually works is negative — alerting on the absence of an expected event.

This type of failure surfaces as the absence of the cron.run signal, which Logystera detects and alerts on early. The Logystera WP plugin emits cron.run every time wp-cron.php completes, captures DISABLE_WP_CRON=true as part of the boot-time wp.environment signal, and tracks wp.cron type=missed_schedule events as they accumulate. Combining these into a single rule — "site has DISABLE_WP_CRON=true AND no cron.run in last 15 minutes" — fires within minutes of the scheduler failing, not days after the first customer complaint.

That rule is the inversion of how WordPress thinks about cron, and it is the only rule that catches this class of failure before users do. Logs reveal it immediately; dashboards never will.

7. Related Silent Failures

Cron failures rarely travel alone. Same signal cluster, same blast radius:

  • Scheduled posts stuck at "Missed schedule"wp.cron type=missed_schedule accumulating; same root cause. If you're seeing this, you also have the cron.run absence problem.
  • Transactional email delays / wp.email with high delay_seconds → mail queues are typically cron-driven. A dead cron means a dead mail queue. Often the first user-visible symptom.
  • Action Scheduler queue growth (WooCommerce)as_enqueued count climbing in wp_actionscheduler_actions. Same failure mode, different surface.
  • Transient bloat / wp_options table growth → expired transients never clean up because the cleanup hook is itself a cron event.
  • Backup plugin silence → UpdraftPlus, BackWPup emit no signal when they don't run. Pair the absence of cron.run with the absence of a backup-completion log line.

Each shares the same pattern: the failure is silent, the logs hold the only evidence, and the absence of a heartbeat signal is the earliest possible alert.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.